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

nordisk/monetize

crates/monetize-ui-web/src/pages/sites.rs

//! **THE SITE ORDER** (owner 2026-10-13) — `/sites`: the operator's own sites, each a form
//! that lays it by this box. Every word is a cell of [`crate::sites`], tested on the host;
//! this only lays them out. Thin: it calls the console's server and renders.

use leptos::prelude::*;

use crate::api::Fail;
use crate::plugins::{
    job_words, outcome_tone, plugins_body, plugins_button, plugins_in_flight, status_path,
    PluginJobJson, UPDATE_PLUGINS_API,
};
use crate::self_box::{
    box_button, box_following, box_words, rolled_back, SelfLookJson, RESTARTING, SELF_API,
    SELF_UPDATE_API,
};
use crate::self_box::{
    cert_button, try_body, CertJson, CertsJson, TriedJson, CERTS_API, CERTS_TRY_API,
};
use crate::sites::{
    answer_path, answer_words, do_body, may_do, DoAnswerJson, DoneJson, KindJson, KindsJson,
    DO_API, KINDS_API,
};
use crate::sites::{
    book_path, button_words, configured, fail_words, form_of, in_flight, label_follows, label_hint,
    may_put, may_terminate, not_managed, offers_terminate, order_body, pay_link, press, put_words,
    secret_words, settle_post, shows_order, site_name, site_words, state_tone, state_words,
    term_of, terminate_body, vendor_choices, OrderedJson, SiteJson, SitePutBody, SitePutJson,
    SiteShownJson, SitesJson, TerminatedJson, LIST_API, ORDER_API, PUT_API, REREAD_MS,
    TERMINATE_API,
};
use crate::sites::{
    box_lines, live_body, live_words, offers_update, release_words, step_lines, update_body,
    update_button, update_failed, update_head, update_in_flight, UpdateJson, LIVE_UPDATE_API,
    UPDATE_API, UPDATE_STATUS_API,
};
use crate::sites::{estate_terminate_body, may_terminate_estate};
use crate::ui::{resolved, Badge, Empty, PageHeader, Panel};

/// **"Update this box"** (owner 2026-10-18): what the ONE box runs and the next release the
/// release bucket offers it; an admin's press asks the box to take it — the box stages it,
/// restarts into it and confirms it after serving (else rolls back by itself) — and the page
/// follows it there: asked → restarting → on trial → up to date.
#[component]
pub fn BoxUpdate(admin: bool) -> impl IntoView {
    let reread = RwSignal::new(0u32);
    let look = RwSignal::new(None::<SelfLookJson>);
    let unreachable = RwSignal::new(false);
    let following = RwSignal::new(false);
    Effect::new(move |_| {
        reread.get();
        leptos::task::spawn_local(async move {
            match crate::api::get::<SelfLookJson>(SELF_API).await {
                Ok(l) => {
                    unreachable.set(false);
                    following.set(box_following(&l));
                    look.set(Some(l));
                }
                Err(_) => unreachable.set(true),
            }
            if following.get_untracked() {
                set_timeout(
                    move || reread.update(|n| *n = n.wrapping_add(1)),
                    std::time::Duration::from_millis(u64::from(REREAD_MS)),
                );
            }
        });
    });
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<Fail>);
    let send = move |_| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        failed.set(None);
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, SelfLookJson>(SELF_UPDATE_API, &serde_json::json!({})).await
            {
                Ok(l) => {
                    following.set(true);
                    look.set(Some(l));
                }
                Err(f) => failed.set(Some(f)),
            }
            busy.set(false);
            reread.update(|n| *n = n.wrapping_add(1));
        });
    };
    view! {
        {move || look.get().map(|_| view! {
            <Panel title="This box">
                <div class="panel-body box-update" id="box-update">
                    <p class="box-words" class:failure=move || look.get().is_some_and(|l| rolled_back(&l)) role="status">{move || if unreachable.get() && following.get() { RESTARTING.to_string() } else { look.get().map(|l| box_words(&l)).unwrap_or_default() }}</p>
                    <p class="actions">
                        <button class="btn" id="box-update-button" title=move || box_button(look.get().as_ref(), admin).2 disabled=move || busy.get() || !box_button(look.get().as_ref(), admin).0 on:click=send>{move || { let b = box_button(look.get().as_ref(), admin); if b.1.is_empty() { "Update this box".to_string() } else { b.1 } }}</button>
                    </p>
                    {move || failed.get().map(|f| view! { <p class="failure" id="box-update-failed" role="alert">{fail_words(f.status, &f.message)}</p> })}
                </div>
            </Panel>
        })}
    }
}

/// **This box's certificates** (owner 2026-10-25): each as the box says it — valid until,
/// next attempt, last reason (edda estate-front's lines) — and an admin's "try now" where the
/// box allows it. Thin: the box decides; this lays its words out.
#[component]
pub fn BoxCerts(admin: bool) -> impl IntoView {
    let reread = RwSignal::new(0u32);
    let certs = LocalResource::new(move || {
        reread.get();
        async move { crate::api::get::<CertsJson>(CERTS_API).await }
    });
    let said = RwSignal::new(None::<String>);
    let failed = RwSignal::new(None::<Fail>);
    let press = move |c: CertJson| {
        failed.set(None);
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, TriedJson>(CERTS_TRY_API, &try_body(&c)).await {
                Ok(t) => said.set(Some(t.words)),
                Err(f) => failed.set(Some(f)),
            }
            reread.update(|n| *n = n.wrapping_add(1));
        });
    };
    view! {
        {move || certs.get().and_then(|r| r.ok()).filter(|c| !c.certificates.is_empty()).map(|c: CertsJson| view! {
            <Panel title="Certificates">
                <div class="panel-body box-certs" id="box-certs">
                    {c.certificates.into_iter().map(|cert| {
                        let (may, why) = cert_button(&cert, admin);
                        let shown = cert.clone();
                        view! {
                            <section class="box-cert">
                                <h3 class="mono">{cert.host.clone()}</h3>
                                {cert.alert.clone().map(|a| view! { <p class="failure" role="alert">{a}</p> })}
                                <pre class="box-cert-lines">{cert.lines.join("\n")}</pre>
                                <p class="actions"><button class="btn box-cert-try" title=why disabled=!may on:click=move |_| press(shown.clone())>"Try now"</button></p>
                            </section>
                        }
                    }).collect_view()}
                    {move || said.get().map(|w| view! { <p class="box-cert-said" role="status">{w}</p> })}
                    {move || failed.get().map(|f| view! { <p class="failure" role="alert">{fail_words(f.status, &f.message)}</p> })}
                </div>
            </Panel>
        })}
    }
}

/// **"Update plugins"** (owner 2026-10-17): the box takes the release's signed plugins from the
/// release bucket — monetize QUEUES the job and this page reads where it stands until it is
/// done, then shows each plugin as monetize said it (unchanged / replaced / refused, and why).
#[component]
pub fn PluginUpdate(admin: bool) -> impl IntoView {
    let job = RwSignal::new(String::new());
    let reread = RwSignal::new(0u32);
    let status = LocalResource::new(move || {
        reread.get();
        let url = status_path(&job.get());
        async move { crate::api::get::<PluginJobJson>(&url).await }
    });
    Effect::new(move |_| {
        if status
            .get()
            .and_then(|r| r.ok())
            .is_some_and(|j| plugins_in_flight(&j))
        {
            set_timeout(
                move || reread.update(|n| *n = n.wrapping_add(1)),
                std::time::Duration::from_millis(u64::from(REREAD_MS)),
            );
        }
    });
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<Fail>);
    let shown = move || status.get().and_then(|r| r.ok());
    let send = move |_| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        failed.set(None);
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, PluginJobJson>(UPDATE_PLUGINS_API, &plugins_body()).await {
                Ok(j) => job.set(j.job),
                Err(f) => failed.set(Some(f)),
            }
            reread.update(|n| *n = n.wrapping_add(1));
            busy.set(false);
        });
    };
    view! {
        <Panel title="Plugins">
            <div class="panel-body plugin-update" id="plugin-update">
                <p class="actions">
                    <button class="btn" id="plugins-update" title=move || plugins_button(shown().as_ref(), admin).1 disabled=move || busy.get() || !plugins_button(shown().as_ref(), admin).0 on:click=send>"Update plugins"</button>
                </p>
                {move || failed.get().map(|f| view! { <p class="failure" id="plugins-failed" role="alert">{fail_words(f.status, &f.message)}</p> })}
                {move || shown().filter(|j| !j.job.is_empty()).map(|j: PluginJobJson| view! {
                    <p class="plugin-job-words" role="status">{job_words(&j)}</p>
                    <table class="plugin-rows" aria-label="each plugin">
                        <tbody>
                            {j.rows.into_iter().map(|r| view! {
                                <tr>
                                    <th scope="row" class="mono">{r.name}</th>
                                    <td>{r.role}</td>
                                    <td><Badge tone=outcome_tone(&r.outcome) label=r.outcome.clone()/></td>
                                    <td class="hint">{r.detail}</td>
                                </tr>
                            }).collect_view()}
                        </tbody>
                    </table>
                })}
            </div>
        </Panel>
    }
}

/// **"Logs"** (lane N2): a narrated test site's boot logs, each machine's tail, read again
/// while the panel is open. Offered to an admin alone (the server refuses anyone else).
#[component]
pub fn SiteLogs(estate: String, admin: bool) -> impl IntoView {
    use crate::narrate::{
        logs_button, logs_path, text_path, vm_head, NarrateJson, REREAD_MS as LOGS_REREAD_MS,
    };
    let open = RwSignal::new(false);
    let reread = RwSignal::new(0u32);
    let logs = RwSignal::new(None::<Result<NarrateJson, Fail>>);
    let site = StoredValue::new(estate);
    Effect::new(move |_| {
        reread.get();
        if !open.get() {
            return;
        }
        let url = logs_path(&site.get_value());
        leptos::task::spawn_local(async move {
            logs.set(Some(crate::api::get::<NarrateJson>(&url).await));
            if open.get_untracked() {
                set_timeout(
                    move || reread.update(|n| *n = n.wrapping_add(1)),
                    std::time::Duration::from_millis(u64::from(LOGS_REREAD_MS)),
                );
            }
        });
    });
    view! {
        {logs_button(admin, false).map(|_| view! {
            <div class="site-logs">
                <p class="actions"><button class="btn site-logs-toggle" on:click=move |_| open.update(|o| *o = !*o)>{move || logs_button(admin, open.get()).unwrap_or_default()}</button></p>
                {move || open.get().then(|| match logs.get() {
                    None => view! { <p class="hint">"Reading the logs…"</p> }.into_any(),
                    Some(Err(f)) => view! { <p class="failure site-logs-failed" role="alert">{fail_words(f.status, &f.message)}</p> }.into_any(),
                    Some(Ok(l)) => view! {
                        <div class="site-logs-body">
                            <p class="hint site-logs-words">{l.words.clone()}</p>
                            {l.vms.iter().map(|v| {
                                let href = text_path(&site.get_value(), &v.vm);
                                view! {
                                    <section class="site-log" data-vm=v.vm.clone()>
                                        <h4>{vm_head(v)} " " <a href=href target="_blank" rel="noopener">"plain text"</a></h4>
                                        <pre class="site-log-text">{v.text.clone()}</pre>
                                    </section>
                                }
                            }).collect_view()}
                        </div>
                    }.into_any(),
                })}
            </div>
        })}
    }
}

/// **An estate's or site's update** (owner 2026-10-14): what each box runs, "Update to release
/// X", and the live-update switch — monetize's words, carried. On the Sites page and on a sold

/// **A product's own orders on a laid site** (MONETIZE-FIX 2026-11-01): each kind the site's
/// product declares, a field per param and one button; the order is queued for the box, which
/// PULLS it, and the box's answer is read again until it came. Thin: every word and check is
/// [`crate::sites`]'s; the server judges and queues.
#[component]
pub fn SiteOrders(estate: String, admin: bool) -> impl IntoView {
    let site = StoredValue::new(estate.clone());
    let kinds = LocalResource::new(move || {
        let url = format!("{KINDS_API}?estate={}", site.get_value());
        async move { crate::api::get::<KindsJson>(&url).await }
    });
    view! {
        {move || kinds.get().and_then(|r| r.ok()).filter(|k: &KindsJson| !k.kinds.is_empty()).map(|k| view! {
            <div class="site-orders" role="group" aria-label="the product's own orders">
                {k.kinds.into_iter().map(|kind| view! { <SiteOrderKind estate=site.get_value() kind=kind admin=admin/> }).collect_view()}
            </div>
        })}
    }
}

#[component]
fn SiteOrderKind(estate: String, kind: KindJson, admin: bool) -> impl IntoView {
    let typed = RwSignal::new(std::collections::BTreeMap::<String, String>::new());
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<Fail>);
    let queued = RwSignal::new(None::<DoneJson>);
    let answer = RwSignal::new(None::<DoAnswerJson>);
    let (site, k) = (StoredValue::new(estate), StoredValue::new(kind.clone()));
    let poll = RwSignal::new(0u32);
    let look = move |seq: u64| {
        leptos::task::spawn_local(async move {
            if let Ok(a) =
                crate::api::get::<DoAnswerJson>(&answer_path(&site.get_value(), seq)).await
            {
                let done = a.answered;
                answer.set(Some(a));
                if !done {
                    set_timeout(
                        move || poll.update(|n| *n = n.wrapping_add(1)),
                        std::time::Duration::from_millis(u64::from(crate::sites::REREAD_MS)),
                    );
                }
            }
        });
    };
    Effect::new(move |_| {
        poll.get();
        if let Some(seq) = queued
            .get_untracked()
            .map(|d| d.seq)
            .filter(|_| poll.get_untracked() > 0)
        {
            look(seq);
        }
    });
    let send = move |_| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        failed.set(None);
        answer.set(None);
        let body = do_body(&site.get_value(), &k.get_value(), &typed.get_untracked());
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, DoneJson>(DO_API, &body).await {
                Ok(d) => {
                    let seq = d.seq;
                    queued.set(Some(d));
                    look(seq);
                }
                Err(f) => failed.set(Some(f)),
            }
            busy.set(false);
        });
    };
    view! {
        <div class="site-order-kind">
            <p><strong class="mono">{kind.kind.clone()}</strong>" "{kind.words.clone()}</p>
            {kind.params.iter().map(|p| {
                let name = p.name.clone();
                let label = format!("{}{}", p.name, if p.required { " *" } else { "" });
                view! {
                    <label class="field">{label}" "
                        <input type="text" title=p.words.clone() on:input=move |ev| { let v = event_target_value(&ev); typed.update(|t| { t.insert(name.clone(), v); }); }/>
                    </label>
                }
            }).collect_view()}
            {move || {
                let (may, why) = may_do(&k.get_value(), &typed.get(), admin);
                view! { <p class="actions"><button class="btn site-order-send" title=why disabled=move || busy.get() || !may on:click=send>"Queue"</button></p> }
            }}
            {move || failed.get().map(|f| view! { <p class="failure" role="alert">{fail_words(f.status, &f.message)}</p> })}
            {move || queued.get().map(|d| view! { <p class="hint">{d.words}</p> })}
            {move || answer.get().map(|a| { let (tone, words) = answer_words(&a); view! { <p class="site-order-answer"><Badge tone=tone label=words/></p> } })}
        </div>
    }
}

/// estate's Guardian page.
#[component]
pub fn EstateUpdate(estate: String, admin: bool) -> impl IntoView {
    let reread = RwSignal::new(0u32);
    let for_status = estate.clone();
    let status = LocalResource::new(move || {
        reread.get();
        let url = format!("{UPDATE_STATUS_API}?estate={}", for_status);
        async move { crate::api::get::<UpdateJson>(&url).await }
    });
    Effect::new(move |_| {
        if status
            .get()
            .and_then(|r| r.ok())
            .is_some_and(|u| update_in_flight(&u))
        {
            set_timeout(
                move || reread.update(|n| *n = n.wrapping_add(1)),
                std::time::Duration::from_millis(u64::from(REREAD_MS)),
            );
        }
    });
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<Fail>);
    let site = StoredValue::new(estate);
    view! {
        <div class="site-update" role="group" aria-label="update">
            {move || failed.get().map(|f| view! { <p class="failure site-update-failed" role="alert">{fail_words(f.status, &f.message)}</p> })}
            {move || status.get().and_then(|r| r.ok()).map(|u: UpdateJson| {
                let button = update_button(&u, admin);
                let head = update_head(&u);
                let (release_name, release_id) = release_words(&u);
                let (live_label, live_on, live_means) = live_words(&u);
                let (for_update, for_live) = (StoredValue::new(u.clone()), StoredValue::new(u.clone()));
                let send = move |_| {
                    if busy.get_untracked() {
                        return;
                    }
                    busy.set(true);
                    failed.set(None);
                    let body = update_body(&site.get_value(), &for_update.get_value());
                    leptos::task::spawn_local(async move {
                        if let Err(f) = crate::api::post::<_, UpdateJson>(UPDATE_API, &body).await {
                            failed.set(Some(f));
                        }
                        reread.update(|n| *n += 1);
                        busy.set(false);
                    });
                };
                let flip = move |_| {
                    if busy.get_untracked() {
                        return;
                    }
                    busy.set(true);
                    failed.set(None);
                    let body = live_body(&site.get_value(), &for_live.get_value());
                    leptos::task::spawn_local(async move {
                        if let Err(f) = crate::api::post::<_, UpdateJson>(LIVE_UPDATE_API, &body).await {
                            failed.set(Some(f));
                        }
                        reread.update(|n| *n += 1);
                        busy.set(false);
                    });
                };
                view! {
                    <p class="site-release">
                        {head.map(|(tone, line)| view! { <span class="site-update-head"><Badge tone=tone label=line/></span> })}
                        " "<span class="hint mono site-release-id" title=release_name>{release_id}</span>
                    </p>
                    <table class="site-boxes" aria-label="what each box runs">
                        <tbody>
                            {box_lines(&u).into_iter().map(|(role, words, tone)| view! {
                                <tr><th scope="row">{role}</th><td><Badge tone=tone label=words/></td></tr>
                            }).collect_view()}
                        </tbody>
                    </table>
                    <ol class="site-update-steps" aria-label="the update, box by box">
                        {step_lines(&u).into_iter().map(|(role, line, failed)| view! {
                            <li class=if failed { "site-update-step failure" } else { "site-update-step" }><strong>{role}</strong>" "{line}</li>
                        }).collect_view()}
                    </ol>
                    {update_failed(&u).then(|| view! { <p class="failure site-update-alert" role="alert">{format!("The update stopped: {}", u.words)}</p> })}
                    {(!u.state.is_empty()).then(|| view! { <p class="site-update-words">{u.words.clone()}</p> })}
                    {button.map(|(may, words, why)| view! {
                        <p class="actions"><button class="btn primary site-update-send" title=why disabled=move || busy.get() || !may on:click=send>{words}</button></p>
                    })}
                    <div class="site-live">
                        <label class="toggle">
                            <input class="site-live-flip" type="checkbox" role="switch" prop:checked=live_on disabled=move || busy.get() || !admin on:change=flip/>
                            " "{live_label}{if live_on { " — on" } else { " — off" }}
                        </label>
                        <p class="hint site-live-means">{live_means}</p>
                    </div>
                }
            })}
        </div>
    }
}

/// **The operator terminates a sold estate** (owner 2026-10-22), on its Guardian page: the
/// buyer's own path in monetize (machines, disks, DNS names and release clones given back), the
/// estate's name typed back, no backup taken. No money moves: nothing is refunded.
#[component]
pub fn EstateTerminate(estate: String, admin: bool) -> impl IntoView {
    let typed = RwSignal::new(String::new());
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<Fail>);
    let ended = RwSignal::new(None::<String>);
    let name = StoredValue::new(estate);
    let end = move || {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        failed.set(None);
        let body = estate_terminate_body(&name.get_value(), &typed.get_untracked());
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, TerminatedJson>(TERMINATE_API, &body).await {
                Ok(t) => ended.set(Some(t.words)),
                Err(f) => failed.set(Some(f)),
            }
            busy.set(false);
        });
    };
    view! {
        <div class="consequence estate-terminate" role="group" aria-label=format!("terminate {}", name.get_value())>
            <p>"Terminate this estate: its machines, disks and DNS names go. Nothing is refunded. Type " <code>{name.get_value()}</code> " to confirm."</p>
            {move || failed.get().map(|f| view! { <p class="failure estate-terminate-failed" role="alert">{fail_words(f.status, &f.message)}</p> })}
            {move || ended.get().map(|w| view! { <p class="estate-terminate-words">{w}</p> })}
            <p>
                <input class="estate-terminate-confirm" type="text" autocomplete="off" prop:value=move || typed.get() on:input=move |ev| typed.set(event_target_value(&ev))/>
                " "
                <button class="btn danger estate-terminate-send" disabled=move || busy.get() || !may_terminate_estate(&name.get_value(), &typed.get(), admin) on:click=move |_| end()>"Terminate"</button>
            </p>
        </div>
    }
}

/// What a person has typed into one site's form. Kept by the page, per site, so that
/// reading the list again (an order moving on) never empties a form somebody is filling in.
#[derive(Clone)]
struct Form {
    size: ArcRwSignal<String>,
    email: ArcRwSignal<String>,
    seed: ArcRwSignal<String>,
    typed: ArcRwSignal<String>,
}

impl Form {
    fn new(size: String) -> Form {
        Form {
            size: ArcRwSignal::new(size),
            email: ArcRwSignal::new(String::new()),
            seed: ArcRwSignal::new(String::new()),
            typed: ArcRwSignal::new(String::new()),
        }
    }
}

#[component]
pub fn Sites() -> impl IntoView {
    crate::api::set_estate(None);
    let feed = crate::feed::use_feed();
    let reload = RwSignal::new(0u32);
    let list = LocalResource::new(move || {
        feed.reread.get();
        reload.get();
        async { crate::api::get::<SitesJson>(LIST_API).await }
    });
    // Drawn again only when the answer CHANGED: a re-read that says the same leaves the
    // page — and whatever is being typed into it — alone.
    let answer = Memo::new(move |_| list.get());
    let forms = StoredValue::new(std::collections::HashMap::<String, Form>::new());
    // While an order is on its way the list is read again, so its state is seen to move.
    Effect::new(move |_| {
        if list
            .get()
            .and_then(|r| r.ok())
            .is_some_and(|j| j.sites.iter().any(|s| in_flight(&s.state)))
        {
            set_timeout(
                move || reload.update(|n| *n = n.wrapping_add(1)),
                std::time::Duration::from_millis(u64::from(REREAD_MS)),
            );
        }
    });
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<Fail>);
    let ordered = RwSignal::new(None::<OrderedJson>);
    let ended = RwSignal::new(None::<String>);
    // The site whose settle is armed, and which action (one at a time).
    let arming = RwSignal::new(None::<(String, String)>);
    let is_admin = move || {
        crate::app::session()
            .get()
            .map(|s| s.is_operator())
            .unwrap_or(false)
    };
    // THE SITE BOOK (2026-10-14): the form being filled in (a new site or an edit), what the
    // box showed for it, and what the last put did.
    let editing = RwSignal::new(None::<SitePutBody>);
    // The form is drawn when it OPENS (a new number) and not on every keystroke, so typing
    // never loses its place: the fields write into `editing`, read untracked.
    let form_open = RwSignal::new(None::<u32>);
    let shown = RwSignal::new(SiteShownJson::default());
    let put_done = RwSignal::new(None::<SitePutJson>);
    let open_form = move |estate: String| {
        failed.set(None);
        put_done.set(None);
        leptos::task::spawn_local(async move {
            match crate::api::get::<SiteShownJson>(&book_path(&estate)).await {
                Ok(j) => {
                    editing.set(Some(form_of(&j, estate.is_empty())));
                    shown.set(j);
                    form_open.update(|n| *n = Some(n.unwrap_or(0).wrapping_add(1)));
                }
                Err(f) => failed.set(Some(f)),
            }
        });
    };
    let send_put = move |_| {
        let Some(body) = editing.get_untracked() else {
            return;
        };
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        failed.set(None);
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, SitePutJson>(PUT_API, &body).await {
                Ok(done) => {
                    // The secrets leave the page with the form.
                    editing.set(None);
                    form_open.set(None);
                    put_done.set(Some(done));
                    reload.update(|n| *n += 1);
                }
                Err(f) => failed.set(Some(f)),
            }
            busy.set(false);
        });
    };
    // Any edit takes the last refusal off the page (owner 2026-11-03): it was about the form
    // as it was, and the button judges the form as it is now.
    let edit = move |f: &dyn Fn(&mut SitePutBody)| {
        failed.set(None);
        editing.update(|b| {
            if let Some(b) = b.as_mut() {
                f(b);
            }
        })
    };
    let typed_label =
        move || editing.with(|b| b.as_ref().map(|b| b.estate.clone()).unwrap_or_default());
    view! {
        <main class="page" id="main">
            <PageHeader title="Sites" subtitle="Your own sites. Ordering one sets it up; what it costs is on its row."/>
            <p class="actions"><button class="btn" id="site-new" disabled=move || !is_admin() || form_open.get().is_some() on:click=move |_| open_form(String::new())>"New site"</button></p>
            {move || view! { <BoxUpdate admin=is_admin()/> }}
            {move || view! { <BoxCerts admin=is_admin()/> }}
            {move || view! { <PluginUpdate admin=is_admin()/> }}
            {move || failed.get().map(|f| view! { <div class="failure" id="site-failed" role="alert"><p>{fail_words(f.status, &f.message)}</p></div> })}
            {move || put_done.get().map(|d| view! {
                <div class="notice" id="site-put-done" role="status">
                    <p>{d.words.clone()}</p>
                    <ul class="site-changed">{d.changed.into_iter().map(|c| view! { <li>{c}</li> }).collect_view()}</ul>
                </div>
            })}
            {move || form_open.get().and_then(|_| editing.get_untracked()).map(|b| {
                let j = shown.get_untracked();
                let creating = b.create;
                let title = if creating { "New site".to_string() } else { format!("Edit {}", b.estate) };
                let products = j.products.iter().map(|p| view! { <option value=p.clone()>{p.clone()}</option> }).collect_view();
                let media = std::iter::once((String::new(), "the shop's".to_string())).chain(j.media_sites.iter().map(|m| (m.clone(), format!("as {m}")))).map(|(v, w)| view! { <option value=v>{w}</option> }).collect_view();
                let held_key = (!creating).then(|| secret_words(j.site.secret_key_set, &j.site.secret_key_fingerprint));
                let held_hook = (!creating).then(|| secret_words(j.site.webhook_secret_set, &j.site.webhook_secret_fingerprint));
                let history = j.history.clone();
                let n = |v: u32| if v == 0 { String::new() } else { v.to_string() };
                view! {
                    <Panel title=title>
                        <div class="panel-body site-form" id="site-form">
                            <div class="form-grid">
                                {creating.then(|| view! {
                                    <label>"Name (label) " <input class="sb-estate" type="text" autocomplete="off" spellcheck="false" placeholder="my-site" prop:value=typed_label on:input=move |ev| { let v = event_target_value(&ev); edit(&|b| b.estate = v.clone()) }/>
                                        {move || label_hint(&typed_label()).map(|w| view! { <span class="hint failure sb-estate-hint" role="status">{w}</span> })}
                                    </label>
                                })}
                                <label>"Domain " <input class="sb-domain" type="text" autocomplete="off" spellcheck="false" prop:value=b.domain.clone() on:input=move |ev| {
                                    let v = event_target_value(&ev);
                                    edit(&|b| {
                                        // A new site's label follows its domain until it is typed by hand.
                                        if let Some(l) = b.create.then(|| label_follows(&b.estate, &b.domain, &v)).flatten() {
                                            b.estate = l;
                                        }
                                        b.domain = v.clone();
                                    })
                                }/></label>
                                <label>"Product " <select class="sb-product" prop:value=b.product.clone() on:change=move |ev| { let v = event_target_value(&ev); edit(&|b| b.product = v.clone()) }>{products}</select></label>
                                {crate::sites::buyer_order_page(&b.domain).map(|page| view! { <span class="hint sb-order-page">"Its buyers order a private estate on the site's own sign-up page, "<code>{page}</code></span> })}
                                <label>"First admin " <input class="sb-first-admin" type="text" autocomplete="off" spellcheck="false" prop:value=b.first_admin.clone() on:input=move |ev| { let v = event_target_value(&ev); edit(&|b| b.first_admin = v.clone()) }/></label>
                                <label><input class="sb-operator" type="checkbox" prop:checked=b.operator_site on:change=move |ev| { let v = event_target_checked(&ev); edit(&|b| b.operator_site = v) }/>" One of my own sites"</label>
                                <label><input class="sb-terminable" type="checkbox" prop:checked=b.terminable on:change=move |ev| { let v = event_target_checked(&ev); edit(&|b| b.terminable = v) }/>" May be terminated"</label>
                                <label>"Stripe account "
                                    <select class="sb-vendor" prop:value=b.vendor.clone() on:change=move |ev| { let v = event_target_value(&ev); edit(&|b| b.vendor = v.clone()) }>
                                        {vendor_choices(&j, creating, "").into_iter().map(|(v, w)| view! { <option value=v>{w}</option> }).collect_view()}
                                    </select>
                                </label>
                                <label>"Mode "
                                    <select class="sb-live" prop:value=if b.live { "live" } else { "test" } on:change=move |ev| { let v = event_target_value(&ev) == "live"; edit(&|b| b.live = v) }>
                                        <option value="test">"test"</option>
                                        <option value="live">"live"</option>
                                    </select>
                                </label>
                                <label>"Stripe secret key " <input class="sb-key" type="password" autocomplete="new-password" spellcheck="false" placeholder=if creating { "sk_test_… / sk_live_…" } else { "keep the one held" } on:input=move |ev| { let v = event_target_value(&ev); edit(&|b| b.vendor_secret_key = v.clone()) }/>
                                    {held_key.map(|w| view! { <span class="hint sb-key-status">{w}</span> })}
                                </label>
                                <label>"Webhook secret " <input class="sb-hook" type="password" autocomplete="new-password" spellcheck="false" placeholder=if creating { "whsec_…" } else { "keep the one held" } on:input=move |ev| { let v = event_target_value(&ev); edit(&|b| b.vendor_webhook_secret = v.clone()) }/>
                                    {held_hook.map(|w| view! { <span class="hint sb-hook-status">{w}</span> })}
                                </label>
                                <label>"Term, days " <input class="sb-term-days" type="number" min="0" prop:value=n(b.terms.term_days) on:input=move |ev| { let v = term_of(&event_target_value(&ev)); edit(&|b| b.terms.term_days = v) }/></label>
                                <label>"Term, months " <input class="sb-term-months" type="number" min="0" prop:value=n(b.terms.term_months) on:input=move |ev| { let v = term_of(&event_target_value(&ev)); edit(&|b| b.terms.term_months = v) }/></label>
                                <label>"Grace, hours " <input class="sb-grace" type="number" min="0" prop:value=n(b.terms.grace_hours) on:input=move |ev| { let v = term_of(&event_target_value(&ev)); edit(&|b| b.terms.grace_hours = v) }/></label>
                                <label>"Margin, basis points " <input class="sb-margin" type="number" min="0" prop:value=n(b.terms.margin_bp) on:input=move |ev| { let v = term_of(&event_target_value(&ev)); edit(&|b| b.terms.margin_bp = v) }/></label>
                                <label>"Media " <select class="sb-media" prop:value=b.media_from.clone() on:change=move |ev| { let v = event_target_value(&ev); edit(&|b| b.media_from = v.clone()) }>{media}</select></label>
                            </div>
                            <p class="hint">{format!("Sold in {} · sizes {} — the shop's. An empty term is the shop's.", if j.currency.is_empty() { "the shop's currency".to_string() } else { j.currency.clone() }, j.sizes.join(", "))}</p>
                            <p class="actions">
                                <button class="btn danger sb-send" title=move || editing.get().map(|b| may_put(&b, is_admin()).1).unwrap_or_default() disabled=move || busy.get() || !editing.get().is_some_and(|b| may_put(&b, is_admin()).0) on:click=send_put>{put_words(&b)}</button>
                                " "
                                <button class="btn sb-cancel" on:click=move |_| { editing.set(None); form_open.set(None); }>"Cancel"</button>
                            </p>
                            {(!history.is_empty()).then(|| view! {
                                <details class="sb-history"><summary>"Changes"</summary><ul>{history.into_iter().map(|h| view! { <li class="mono">{h}</li> }).collect_view()}</ul></details>
                            })}
                        </div>
                    </Panel>
                }
            })}
            {move || ordered.get().map(|o| view! {
                <Panel title="Ordered">
                    <div class="panel-body" id="site-ordered">
                        <p>{o.words.clone()}</p>
                        // ONE "Pay <amount>" (owner's buy-flow rule): it is on the site's row, where its state is.
                        {o.checkout_url.starts_with("https://").then(|| view! { <p class="hint" id="site-pay-below">{format!("Pay {} on the site's row below.", o.price_words)}</p> })}
                    </div>
                </Panel>
            })}
            {move || ended.get().map(|words| view! { <p class="notice" id="site-ended">{words}</p> })}
            {move || resolved(answer.get(), None, move |j: SitesJson| {
                let admin = is_admin();
                if j.sites.is_empty() {
                    return view! { <Empty title="No sites" detail="No sites yet."/> }.into_any();
                }
                let sizes = j.sizes.clone();
                view! {
                    <div class="stack">
                        {j.sites.into_iter().map(|s: SiteJson| {
                            let form = forms.with_value(|m| m.get(&s.estate).cloned()).unwrap_or_else(|| {
                                let form = Form::new(sizes.first().cloned().unwrap_or_default());
                                forms.update_value(|m| {
                                    m.insert(s.estate.clone(), form.clone());
                                });
                                form
                            });
                            let (size, email, seed, typed) = (RwSignal::from(form.size), RwSignal::from(form.email), RwSignal::from(form.seed), RwSignal::from(form.typed));
                            let tone = state_tone(&s.state);
                            let pay = pay_link(&s);
                            let words = button_words(&s);
                            let name = site_name(&s).to_string();
                            let (for_press, for_order, for_title) = (s.clone(), s.clone(), s.clone());
                            let (for_end, for_may) = (StoredValue::new(s.clone()), s.clone());
                            let options = sizes.iter().map(|z| view! { <option value=z.clone()>{z.clone()}</option> }).collect_view();
                            let order = move |_| {
                                if busy.get_untracked() {
                                    return;
                                }
                                busy.set(true);
                                failed.set(None);
                                let body = order_body(&for_order, &size.get_untracked(), &email.get_untracked(), &seed.get_untracked());
                                leptos::task::spawn_local(async move {
                                    match crate::api::post::<_, OrderedJson>(ORDER_API, &body).await {
                                        Ok(o) => {
                                            ordered.set(Some(o));
                                            reload.update(|n| *n += 1);
                                        }
                                        Err(f) => failed.set(Some(f)),
                                    }
                                    busy.set(false);
                                });
                            };
                            // One press carries the name typed back; no backup is offered.
                            let end = move || {
                                if busy.get_untracked() {
                                    return;
                                }
                                busy.set(true);
                                failed.set(None);
                                let body = terminate_body(&for_end.get_value(), &typed.get_untracked());
                                leptos::task::spawn_local(async move {
                                    match crate::api::post::<_, TerminatedJson>(TERMINATE_API, &body).await {
                                        Ok(t) => {
                                            ended.set(Some(t.words));
                                            reload.update(|n| *n += 1);
                                        }
                                        Err(f) => failed.set(Some(f)),
                                    }
                                    busy.set(false);
                                });
                            };
                            let for_settle = StoredValue::new(s.clone());
                            let settle = move |action: String| {
                                if busy.get_untracked() {
                                    return;
                                }
                                busy.set(true);
                                failed.set(None);
                                let (path, body) = settle_post(&for_settle.get_value(), &action);
                                leptos::task::spawn_local(async move {
                                    match crate::api::post::<_, serde_json::Value>(&path, &body).await {
                                        Ok(r) => {
                                            arming.set(None);
                                            ended.set(r["words"].as_str().filter(|w| !w.is_empty()).map(str::to_owned));
                                            reload.update(|n| *n += 1);
                                        }
                                        Err(f) => failed.set(Some(f)),
                                    }
                                    busy.set(false);
                                });
                            };
                            // A32-29: the server's settles for this site; the page draws them for an admin.
                            let settles = if admin { s.actions.clone() } else { Vec::new() };
                            let estate_key = s.estate.clone();
                            view! {
                                <section class="panel site" data-site=s.estate.clone()>
                                    <header>
                                        <h2>{name.clone()}</h2>
                                        <div class="actions"><button class="btn site-edit" disabled=move || !is_admin() on:click={
                                            let e = s.estate.clone();
                                            move |_| open_form(e.clone())
                                        }>"Edit"</button></div>
                                    </header>
                                    <div class="panel-body stack site-body">
                                    {
                                        let (all, line) = configured(&s);
                                        view! { <p class=if all { "site-configured hint" } else { "site-configured failure" }>{line}</p> }
                                    }
                                    <p class="site-state"><Badge tone=tone label=state_words(&s.state)/>" "{site_words(&s)}</p>
                                    {not_managed(&s).map(|w| view! { <p class="hint site-not-managed">{w}</p> })}
                                    {pay.map(|(w, url)| view! { <p><a class="btn danger site-pay" href=url target="_blank" rel="noopener noreferrer">{w}</a></p> })}
                                    {crate::sites::door_line(&s).map(|w| view! { <p class="hint site-first-admin-door">{w}</p> })}
                                    {(!settles.is_empty()).then(|| view! {
                                        <div class="actions site-settle" role="group" aria-label=format!("settle {name}")>
                                            {settles.into_iter().map(|a| {
                                                let key = estate_key.clone();
                                                let verb = a.verb.clone();
                                                let armed = {
                                                    let verb = verb.clone();
                                                    move || arming.get().is_some_and(|(e, v)| e == key && v == verb)
                                                };
                                                let arm_key = estate_key.clone();
                                                let (label, confirm) = (a.label.clone(), a.confirm.clone());
                                                view! {
                                                    <span class="money-action">
                                                        {move || if armed() {
                                                            let v = verb.clone();
                                                            view! {
                                                                <button class="btn danger site-settle-confirm" data-action=verb.clone() disabled=move || busy.get() on:click=move |_| settle(v.clone())>{confirm.clone()}</button>
                                                                <button class="btn quiet" on:click=move |_| arming.set(None)>"Cancel"</button>
                                                            }.into_any()
                                                        } else {
                                                            let (k, v) = (arm_key.clone(), verb.clone());
                                                            view! { <button class="btn site-settle-send" data-action=verb.clone() on:click=move |_| arming.set(Some((k.clone(), v.clone())))>{label.clone()}</button> }.into_any()
                                                        }}
                                                    </span>
                                                }
                                            }).collect_view()}
                                        </div>
                                    })}
                                    {if !shows_order(&s) {
                                        ().into_any()
                                    } else if s.orderable {
                                        view! {
                                            <div class="actions site-order" role="group" aria-label=format!("order {name}")>
                                                <label>"Size "
                                                    <select class="site-size" prop:value=move || size.get() on:change=move |ev| size.set(event_target_value(&ev))>
                                                        {options}
                                                    </select>
                                                </label>
                                                <label>" First admin's e-mail "
                                                    <input class="site-email" type="email" autocomplete="off" prop:value=move || email.get() on:input=move |ev| email.set(event_target_value(&ev))/>
                                                </label>
                                                <label>" Seed key (public, optional) "
                                                    <input class="site-seed" type="text" autocomplete="off" spellcheck="false" prop:value=move || seed.get() on:input=move |ev| seed.set(event_target_value(&ev))/>
                                                </label>
                                                <button class="btn danger site-order-send" title=move || press(&for_title, &email.get(), admin).1 disabled=move || busy.get() || !press(&for_press, &email.get(), admin).0 on:click=order>{words}</button>
                                            </div>
                                        }.into_any()
                                    } else {
                                        // Not orderable: monetize's own reason, and no form.
                                        (!s.why_not.is_empty()).then(|| view! { <p class="hint site-why-not">{format!("Cannot be ordered: {}", s.why_not)}</p> }).into_any()
                                    }}
                                    {offers_update(&s).then(|| view! { <EstateUpdate estate=s.estate.clone() admin=admin/> })}
                                    {offers_update(&s).then(|| view! { <SiteOrders estate=s.estate.clone() admin=admin/> })}
                                    {(s.state != "none").then(|| view! { <SiteLogs estate=s.estate.clone() admin=admin/> })}
                                    {offers_terminate(&s).then(|| view! {
                                        <div class="consequence site-terminate" role="group" aria-label=format!("terminate {name}")>
                                            <p>"Terminate this site: its machines, disks and DNS names go. Type " <code>{name.clone()}</code> " to confirm."</p>
                                            <p>
                                                <input class="site-terminate-confirm" type="text" autocomplete="off" prop:value=move || typed.get() on:input=move |ev| typed.set(event_target_value(&ev))/>
                                                " "
                                                {move || {
                                                    let may = may_terminate(&for_may, &typed.get(), admin);
                                                    view! { <button class="btn danger site-terminate-send" disabled=move || busy.get() || !may on:click=move |_| end()>"Terminate"</button> }
                                                }}
                                            </p>
                                        </div>
                                    })}
                                </div>
                                </section>
                            }
                        }).collect_view()}
                    </div>
                }.into_any()
            })}
        </main>
    }
}