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

nordisk/holger-ui

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