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