src/pages/plan.rs
//! **My plan** (`/plan`): the paid half of the buyer's pages (A28 M13: compiled only under
//! `metered`; moved out of `pages::mine`). Every fact on it is holger's or monetize's answer,
//! turned into lines by `crate::purchase`. It carries Gunnar's Account page (A16): the Plan
//! panel, Prolong, Past purchases and Your purchases, each over the site's shared buy doors; an
//! instance is ordered and managed on the sign-up page.
//!
//! `/plan` is not a route of the router: it is [`at_its_address`], reached through the
//! router's fallback (`app::unmatched`), so an open console has no such address. gunnar-ui
//! reaches its vendor's return page the same way (`pages::subscription::vendor_return`).
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::components::feedback::{Badge, EmptyState, Loading, Tone};
use crate::mine::GrpcDoor;
use crate::pages::mine::{MineTabs, Refusal};
use crate::purchase;
/// The page's address.
pub const PATH: &str = "/plan";
/// **The page at `path`, if `path` is its address**: the router's fallback asks.
pub fn at_its_address(path: &str) -> Option<AnyView> {
(path.trim_end_matches('/') == PATH).then(|| view! { <MyPlan/> }.into_any())
}
/// ★ **Your purchases** (A14, Gunnar's Account page): the person's instances, each with "Grow
/// <company>'s Holger" and "Manage <company>" on this site; for an account without room here,
/// its lines and the way to buy room here. Drawn only where something was bought or is offered.
#[component]
fn YourPurchases() -> impl IntoView {
#[cfg(target_arch = "wasm32")]
let bought = LocalResource::new(|| async { purchase::load_purchases().await });
#[cfg(not(target_arch = "wasm32"))]
let bought = LocalResource::new(|| async { None::<purchase::Purchases> });
view! {
{move || match bought.get() {
Some(Some(p)) if !p.is_empty() => view! {
<h2>"Your purchases"</h2>
{(!p.estates.is_empty()).then(|| view! {
<div class="tablewrap">
<table>
<thead>
<tr>
<th scope="col">"Instance"</th>
<th scope="col">"Size"</th>
<th scope="col">"Paid until"</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{p.estates.clone().into_iter().map(|e| view! {
<tr>
<td>
{if e.open_url.is_empty() { view! { <span>{e.host.clone()}</span> }.into_any() } else { view! { <a rel="external" href=e.open_url.clone()>{e.host.clone()}</a> }.into_any() }}
<span class="count">{format!(" {}", e.state)}</span>
</td>
<td>{e.step.clone()}<span class="count">{format!(" {}", e.monthly)}</span></td>
<td>{e.paid_until.clone()}</td>
<td>
{(!e.grow_url.is_empty()).then(|| view! { <a class="btn primary" rel="external" href=e.grow_url.clone()>{e.grow.clone()}</a> })}
" "
{(!e.renew_url.is_empty()).then(|| view! { <a class="btn" rel="external" href=e.renew_url.clone()>{e.manage.clone()}</a> })}
" "
<InstanceButton company=e.company.clone() door=purchase::SIGN_IN_AGAIN_DOOR label=purchase::SIGN_IN_AGAIN_LABEL done="Sent."/>
" "
<InstanceButton company=e.company.clone() door=purchase::RESTORE_FRONT_DOOR label=purchase::RESTORE_FRONT_LABEL done="Restored."/>
</td>
</tr>
}).collect_view()}
</tbody>
</table>
</div>
})}
{p.lines.clone().into_iter().map(|(words, url)| view! { <p><a rel="external" href=url>{words}</a></p> }).collect_view()}
{p.buy.clone().map(|(words, url)| view! { <p><a class="btn primary" rel="external" href=url>{words}</a></p> })}
}.into_any(),
_ => ().into_any(),
}}
}
}
/// **"Send my sign-in link again"** and **"Restore front"** on an instance's row (Gunnar's S10
/// and S8): one call of the site's shared door; the row shows its words, or its refusal.
#[component]
fn InstanceButton(company: String, door: &'static str, label: &'static str, done: &'static str) -> 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();
spawn_local(async move {
#[cfg(target_arch = "wasm32")]
let got = purchase::site_post(door, &serde_json::json!({ "company": company })).await.map(|b| purchase::words_of(&b, done));
#[cfg(not(target_arch = "wasm32"))]
let got: Result<String, String> = { let _ = (company, door); Ok(done.to_owned()) };
let _ = said.try_set(Some(got));
let _ = busy.try_set(false);
});
};
view! {
<button type="button" class="btn" disabled=move || busy.get() on:click=ask>{label}</button>
{move || said.get().map(|w| match w {
Ok(w) => view! { <span class="count">{format!(" {w}")}</span> }.into_any(),
Err(w) => view! { <span class="failure">{format!(" {w}")}</span> }.into_any(),
})}
}
}
/// Sends the browser to the vendor's page (same tab).
fn go_to(url: &str) {
#[cfg(target_arch = "wasm32")]
if let Some(w) = web_sys::window() {
let _ = w.location().assign(url);
}
#[cfg(not(target_arch = "wasm32"))]
let _ = url;
}
/// ★ **The Plan panel** (A16, Gunnar's Account page): "Free" and "Buy a plan", or the plan's
/// name, its storage, the term it is paid until and "Grow your account"; with a plan, Prolong
/// (monetize's price and the VAT note). Every word about the plan is monetize's.
#[component]
fn AccountPlanPanel() -> impl IntoView {
#[cfg(target_arch = "wasm32")]
let plan = LocalResource::new(|| async { purchase::site_get(purchase::BUNDLES_DOOR).await.and_then(|b| purchase::account_plan_of(&b)) });
#[cfg(not(target_arch = "wasm32"))]
let plan = LocalResource::new(|| async { None::<purchase::AccountPlan> });
#[cfg(target_arch = "wasm32")]
let renewal = LocalResource::new(|| async { purchase::site_get(purchase::RENEWAL_DOOR).await.map(|b| purchase::renewal_line_of(&b)) });
#[cfg(not(target_arch = "wasm32"))]
let renewal = LocalResource::new(|| async { None::<purchase::RenewalLine> });
let busy = RwSignal::new(false);
let fault = RwSignal::new(None::<String>);
let prolong = move |_| {
busy.set(true);
fault.set(None);
spawn_local(async move {
#[cfg(target_arch = "wasm32")]
let got = purchase::site_post(purchase::RENEW_DOOR, &serde_json::json!({})).await;
#[cfg(not(target_arch = "wasm32"))]
let got: Result<String, String> = Err(String::new());
match got.map(|b| purchase::pay_now(&b).ok_or_else(|| purchase::words_of(&b, purchase::NO_LINK))) {
Ok(Ok(url)) => go_to(&url),
Ok(Err(words)) | Err(words) => fault.set(Some(words)),
}
busy.set(false);
});
};
view! {
{move || plan.get().flatten().map(|p| {
let planned = p.planned;
view! {
<h2>{purchase::PLAN_PANEL}</h2>
<div class="panel-body tier" id="account-plan">
<p class="tier-name" id="plan-name"><strong>{p.name.clone()}</strong></p>
{(!p.storage.is_empty() || !p.paid_until.is_empty()).then(|| view! {
<dl class="metrics">
{(!p.storage.is_empty()).then(|| view! { <dt>"Storage"</dt><dd>{p.storage.clone()}</dd> })}
{(!p.paid_until.is_empty()).then(|| view! { <dt>"Paid until"</dt><dd>{p.paid_until.clone()}</dd> })}
</dl>
})}
{(!p.ends.is_empty()).then(|| view! { <p class="tier-ends">{p.ends.clone()}</p> })}
{(!p.banner.is_empty()).then(|| view! { <p class=if p.banner_bad { "room-banner bad" } else { "room-banner warn" } role="status">{p.banner.clone()}</p> })}
<div class="account-cta">
<a class="btn primary account-cta-btn" id="account-buy-grow" rel="external" href=purchase::PLAN_PAGE>{p.button}</a>
</div>
{planned.then(|| view! {
{move || match renewal.get().flatten() {
None => ().into_any(),
Some(purchase::RenewalLine::Renew(price)) => view! {
<div class="row tier-more">
<button class="btn" id="account-prolong" on:click=prolong disabled=move || busy.get()>{purchase::PROLONG_LABEL}</button>
{(!price.is_empty()).then(|| view! { <span class="count">{format!(" {price}")}</span> })}
</div>
}.into_any(),
Some(purchase::RenewalLine::NothingYet) => ().into_any(),
Some(purchase::RenewalLine::Said(said)) => view! { <p class="count" id="plan-prolong-refused">{said}</p> }.into_any(),
}}
{move || fault.get().map(|f| view! { <p class="failure">{f}</p> })}
})}
</div>
}
})}
}
}
/// ★ **Past purchases** (A16, Gunnar's): when, what, monetize's price words, how it ended, and
/// "Resume payment" for an order still open at the vendor.
#[component]
fn PastPurchases() -> impl IntoView {
#[cfg(target_arch = "wasm32")]
let rows = LocalResource::new(|| async { purchase::site_get(purchase::HISTORY_DOOR).await.map(|b| purchase::past_of(&b)).unwrap_or_default() });
#[cfg(not(target_arch = "wasm32"))]
let rows = LocalResource::new(|| async { Vec::<purchase::PastRow>::new() });
view! {
{move || rows.get().filter(|r| !r.is_empty()).map(|rows| view! {
<h2>{purchase::PAST_PURCHASES}</h2>
<div class="tablewrap">
<table>
<tbody>
{rows.into_iter().map(|r| view! {
<tr>
<td>{r.date.clone()}</td>
<td>{r.what.clone()}</td>
<td>{r.price.clone()}</td>
<td><Badge tone=Tone::Neutral label=r.outcome.clone()/></td>
<td>{r.resumable.then(|| view! { <ResumeButton reference=r.reference.clone()/> })}</td>
</tr>
}).collect_view()}
</tbody>
</table>
</div>
})}
}
}
/// **"Resume payment"** (Gunnar's): the order's status asked again, and the vendor's page if it
/// still waits for payment.
#[component]
fn ResumeButton(reference: String) -> impl IntoView {
let said = RwSignal::new(None::<&'static str>);
let busy = RwSignal::new(false);
let resume = move |_| {
let reference = reference.clone();
busy.set(true);
spawn_local(async move {
#[cfg(target_arch = "wasm32")]
let body = purchase::site_get(&format!("{}{}", purchase::STATUS_DOOR, crate::util::enc(&reference))).await;
#[cfg(not(target_arch = "wasm32"))]
let body: Option<String> = { let _ = reference; None };
match body.as_deref().and_then(purchase::pay_now) {
Some(url) => {
said.set(Some(purchase::LEAVING));
go_to(&url);
}
None => said.set(Some(purchase::NO_LINK)),
}
busy.set(false);
});
};
view! {
<button class="btn primary pay" on:click=resume disabled=move || busy.get()>{purchase::RESUME_LABEL}</button>
{move || said.get().map(|s| view! { <p class="count">{s}</p> })}
}
}
#[component]
pub fn MyPlan() -> impl IntoView {
crate::webio::set_title("My plan");
let view_of = LocalResource::new(|| async { purchase::load_plan(&GrpcDoor).await });
view! {
<section>
<MineTabs/>
<AccountPlanPanel/>
<YourPurchases/>
<h2>"My plan"</h2>
{move || match view_of.get() {
None => view! { <Loading what="your plan"/> }.into_any(),
Some(Err(r)) => view! { <Refusal context="holger refused your plan" refusal=r/> }.into_any(),
Some(Ok(v)) if v.rows.is_empty() => view! { <EmptyState message=v.words/> }.into_any(),
Some(Ok(v)) => view! {
<div class="tablewrap">
<table>
<thead>
<tr>
<th scope="col">"Repository"</th>
<th scope="col">"Plan"</th>
<th scope="col">"Renewal"</th>
<th scope="col">"Room used"</th>
<th scope="col">"More room"</th>
</tr>
</thead>
<tbody>
{v.rows.into_iter().map(|p| view! {
<tr>
<td><a href=format!("/repo/{}", crate::util::enc(&p.name))>{p.name.clone()}</a></td>
<td><Badge tone=p.tone label=p.state.clone()/>{(!p.plan.is_empty()).then(|| view! { <span class="count mono">{format!(" {}", p.plan)}</span> })}</td>
<td class="wrap">{p.words.clone()}</td>
<td>
{p.room.clone()}
{p.percent.map(|pct| view! { <meter min="0" max="100" value=pct.to_string() aria-label="share of the room bought that is used"></meter> })}
</td>
<td>
{if p.shop_url.is_empty() {
view! { <span class="count">{p.shop_words.clone()}</span> }.into_any()
} else {
view! { <a class="btn primary" rel="external" href=p.shop_url.clone()>"Grow your account"</a> }.into_any()
}}
</td>
</tr>
}).collect_view()}
</tbody>
</table>
</div>
}.into_any(),
}}
<PastPurchases/>
</section>
}
}