feat: new billing page

This commit is contained in:
2025-03-05 15:33:32 +00:00
parent 7bdea28bc9
commit b52735a0a4
12 changed files with 234 additions and 147 deletions

View File

@ -105,7 +105,7 @@ export class LNVpsApi {
constructor( constructor(
readonly url: string, readonly url: string,
readonly publisher: EventPublisher | undefined, readonly publisher: EventPublisher | undefined,
) { } ) {}
async getAccount() { async getAccount() {
const { data } = await this.#handleResponse<ApiResponse<AccountDetail>>( const { data } = await this.#handleResponse<ApiResponse<AccountDetail>>(
@ -187,13 +187,18 @@ export class LNVpsApi {
return data; return data;
} }
async orderVm(template_id: number, image_id: number, ssh_key_id: number, ref_code?: string) { async orderVm(
template_id: number,
image_id: number,
ssh_key_id: number,
ref_code?: string,
) {
const { data } = await this.#handleResponse<ApiResponse<VmInstance>>( const { data } = await this.#handleResponse<ApiResponse<VmInstance>>(
await this.#req("/api/v1/vm", "POST", { await this.#req("/api/v1/vm", "POST", {
template_id, template_id,
image_id, image_id,
ssh_key_id, ssh_key_id,
ref_code ref_code,
}), }),
); );
return data; return data;

View File

@ -22,7 +22,7 @@ const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>(
} }
}} }}
className={classNames( className={classNames(
"py-1 px-2 rounded-xl font-medium relative", "py-2 px-3 rounded-xl font-medium relative",
{ {
"bg-neutral-800 cursor-not-allowed text-neutral-500": "bg-neutral-800 cursor-not-allowed text-neutral-500":
!hasBg && props.disabled === true, !hasBg && props.disabled === true,

View File

@ -18,16 +18,18 @@ export default function VpsPayButton({ spec }: { spec: VmTemplate }) {
const navigte = useNavigate(); const navigte = useNavigate();
if (!login) { if (!login) {
return <AsyncButton return (
className={`${classNames} bg-red-900`} <AsyncButton
onClick={() => className={`${classNames} bg-red-900`}
navigte("/login", { onClick={() =>
state: spec, navigte("/login", {
}) state: spec,
} })
> }
Login To Order >
</AsyncButton> Login To Order
</AsyncButton>
);
} }
return ( return (
<AsyncButton <AsyncButton

View File

@ -41,7 +41,12 @@ export default function VpsInstanceRow({
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{isExpired && ( {isExpired && (
<> <>
<Link to="/vm/renew" className="text-red-500 text-sm" state={vm}> <Link
to="/vm/billing/renew"
className="text-red-500 text-sm"
state={vm}
onClick={(e) => e.stopPropagation()}
>
Expired Expired
</Link> </Link>
</> </>

View File

@ -48,7 +48,9 @@ export default function VpsPayment({
className="cursor-pointer rounded-xl overflow-hidden" className="cursor-pointer rounded-xl overflow-hidden"
/> />
{(payment.amount / 1000).toLocaleString()} sats {(payment.amount / 1000).toLocaleString()} sats
<div className="monospace select-all break-all text-center text-sm">{payment.invoice}</div> <div className="monospace select-all break-all text-center text-sm">
{payment.invoice}
</div>
</div> </div>
); );
} }

View File

@ -13,6 +13,7 @@ import SignUpPage from "./pages/sign-up.tsx";
import { TosPage } from "./pages/terms.tsx"; import { TosPage } from "./pages/terms.tsx";
import { StatusPage } from "./pages/status.tsx"; import { StatusPage } from "./pages/status.tsx";
import { AccountSettings } from "./pages/account-settings.tsx"; import { AccountSettings } from "./pages/account-settings.tsx";
import { VmBillingPage } from "./pages/vm-billing.tsx";
const system = new NostrSystem({ const system = new NostrSystem({
automaticOutboxModel: false, automaticOutboxModel: false,
@ -50,9 +51,13 @@ const router = createBrowserRouter([
element: <OrderPage />, element: <OrderPage />,
}, },
{ {
path: "/vm/:action?", path: "/vm",
element: <VmPage />, element: <VmPage />,
}, },
{
path: "/vm/billing/:action?",
element: <VmBillingPage />,
},
{ {
path: "/tos", path: "/tos",
element: <TosPage />, element: <TosPage />,

View File

@ -21,11 +21,12 @@ export default function HomePage() {
dedicated support, tailored to your needs. dedicated support, tailored to your needs.
</div> </div>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{offers?.map((a) => ( {offers?.map((a) => <VpsCard spec={a} key={a.id} />)}
<VpsCard spec={a} key={a.id} /> {offers !== undefined && offers.length === 0 && (
))} <div className="text-red-500 bold text-xl uppercase">
{offers !== undefined && offers.length === 0 && No offers available
<div className="text-red-500 bold text-xl uppercase">No offers available</div>} </div>
)}
</div> </div>
<small className="text-neutral-400"> <small className="text-neutral-400">

View File

@ -2,7 +2,6 @@ import { Link, Outlet } from "react-router-dom";
import LoginButton from "../components/login-button"; import LoginButton from "../components/login-button";
import { saveRefCode } from "../ref"; import { saveRefCode } from "../ref";
export default function Layout() { export default function Layout() {
saveRefCode(); saveRefCode();
return ( return (

View File

@ -31,9 +31,14 @@ export default function OrderPage() {
setOrderError(""); setOrderError("");
try { try {
const ref = getRefCode(); const ref = getRefCode();
const newVm = await login.api.orderVm(template.id, useImage, useSshKey, ref?.code); const newVm = await login.api.orderVm(
template.id,
useImage,
useSshKey,
ref?.code,
);
clearRefCode(); clearRefCode();
navigate("/vm/renew", { navigate("/vm/billing/renew", {
state: newVm, state: newVm,
}); });
} catch (e) { } catch (e) {

82
src/pages/vm-billing.tsx Normal file
View File

@ -0,0 +1,82 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { VmInstance, VmPayment } from "../api";
import VpsPayment from "../components/vps-payment";
import useLogin from "../hooks/login";
import { AsyncButton } from "../components/button";
import CostLabel from "../components/cost";
export function VmBillingPage() {
const location = useLocation() as { state?: VmInstance };
const params = useParams();
const login = useLogin();
const navigate = useNavigate();
const [payment, setPayment] = useState<VmPayment>();
const [state, setState] = useState<VmInstance | undefined>(location?.state);
async function reloadVmState() {
if (!state) return;
const newState = await login?.api.getVm(state.id);
setState(newState);
return newState;
}
const renew = useCallback(
async function () {
if (!login?.api || !state) return;
const p = await login?.api.renewVm(state.id);
setPayment(p);
},
[login?.api, state],
);
useEffect(() => {
if (params["action"] === "renew" && login && state) {
renew()
}
}, [login, state, params, renew]);
if (!state) return;
const expireDate = new Date(state.expires);
const days =
(expireDate.getTime() - new Date().getTime()) / 1000 / 24 / 60 / 60;
return (
<div className="flex flex-col gap-4">
<Link to={"/vm"} state={state}>
&lt; Back
</Link>
<div className="text-xl bg-neutral-900 rounded-xl px-3 py-4 flex justify-between items-center">
<div>Renewal for #{state.id}</div>
<CostLabel cost={state.template.cost_plan} />
</div>
{days > 0 && (
<div>
Expires: {expireDate.toDateString()} ({Math.floor(days)} days)
</div>
)}
{days < 0 && params["action"] !== "renew"
&& <div className="text-red-500 text-xl">Expired</div>}
{!payment && (
<div>
<AsyncButton onClick={renew}>Extend Now</AsyncButton>
</div>
)}
{payment && (
<>
<h3>Renew VPS</h3>
<VpsPayment
payment={payment}
onPaid={async () => {
setPayment(undefined);
if (!login?.api || !state) return;
const s = await reloadVmState();
if (params["action"] === "renew") {
navigate("/vm", { state: s });
}
}}
/>
</>
)}
</div>
);
}

View File

@ -1,12 +1,10 @@
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { VmInstance, VmIpAssignment, VmPayment } from "../api"; import { VmInstance, VmIpAssignment } from "../api";
import VpsInstanceRow from "../components/vps-instance"; import VpsInstanceRow from "../components/vps-instance";
import useLogin from "../hooks/login"; import useLogin from "../hooks/login";
import { useCallback, useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import VpsPayment from "../components/vps-payment";
import CostLabel from "../components/cost";
import { AsyncButton } from "../components/button"; import { AsyncButton } from "../components/button";
import { Terminal } from "@xterm/xterm"; import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
@ -19,11 +17,10 @@ const fit = new FitAddon();
export default function VmPage() { export default function VmPage() {
const location = useLocation() as { state?: VmInstance }; const location = useLocation() as { state?: VmInstance };
const { action } = useParams();
const login = useLogin(); const login = useLogin();
const navigate = useNavigate(); const navigate = useNavigate();
const [state, setState] = useState<VmInstance | undefined>(location?.state); const [state, setState] = useState<VmInstance | undefined>(location?.state);
const [payment, setPayment] = useState<VmPayment>();
const [term] = useState<Terminal>(); const [term] = useState<Terminal>();
const termRef = useRef<HTMLDivElement | null>(null); const termRef = useRef<HTMLDivElement | null>(null);
const [editKey, setEditKey] = useState(false); const [editKey, setEditKey] = useState(false);
@ -31,15 +28,6 @@ export default function VmPage() {
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [key, setKey] = useState(state?.ssh_key?.id ?? -1); const [key, setKey] = useState(state?.ssh_key?.id ?? -1);
const renew = useCallback(
async function () {
if (!login?.api || !state) return;
const p = await login?.api.renewVm(state.id);
setPayment(p);
},
[login?.api, state],
);
async function reloadVmState() { async function reloadVmState() {
if (!state) return; if (!state) return;
const newState = await login?.api.getVm(state.id); const newState = await login?.api.getVm(state.id);
@ -47,35 +35,55 @@ export default function VmPage() {
} }
function ipRow(a: VmIpAssignment, reverse: boolean) { function ipRow(a: VmIpAssignment, reverse: boolean) {
return <div return (
key={a.id} <div
className="bg-neutral-900 px-2 py-3 rounded-lg flex gap-2 flex-col justify-center" key={a.id}
> className="bg-neutral-900 px-2 py-3 rounded-lg flex gap-2 flex-col justify-center"
<div> >
<span className="select-none">IP: </span> <div>
<span className="select-all">{a.ip.split("/")[0]}</span> <span className="select-none">IP: </span>
<span className="select-all">{a.ip.split("/")[0]}</span>
</div>
{a.forward_dns && (
<div className="text-sm select-none">
DNS: <span className="select-all">{a.forward_dns}</span>
</div>
)}
{reverse && (
<div className="text-sm select-none flex items-center gap-2">
<div>
PTR: <span className="select-all">{a.reverse_dns}</span>
</div>
<Icon
name="pencil"
className="inline"
size={15}
onClick={() => setEditReverse(a)}
/>
</div>
)}
</div> </div>
{a.forward_dns && <div className="text-sm select-none">DNS: <span className="select-all">{a.forward_dns}</span></div>} );
{reverse && <div className="text-sm select-none flex items-center gap-2">
<div>PTR: <span className="select-all">{a.reverse_dns}</span></div>
<Icon name="pencil" className="inline" size={15} onClick={() => setEditReverse(a)} />
</div>}
</div>
} }
function networkInfo() { function networkInfo() {
if (!state) return; if (!state) return;
if ((state.ip_assignments?.length ?? 0) === 0) { if ((state.ip_assignments?.length ?? 0) === 0) {
return <div className="text-sm text-red-500">No IP's assigned</div> return <div className="text-sm text-red-500">No IP's assigned</div>;
} }
return <> return (
{state.ip_assignments?.map(i => ipRow(i, true))} <>
{ipRow({ {state.ip_assignments?.map((i) => ipRow(i, true))}
id: -1, {ipRow(
ip: toEui64("2a13:2c0::", state.mac_address), {
gateway: "" id: -1,
}, false)} ip: toEui64("2a13:2c0::", state.mac_address),
</> gateway: "",
},
false,
)}
</>
);
} }
/*async function openTerminal() { /*async function openTerminal() {
@ -104,17 +112,10 @@ export default function VmPage() {
} }
}, [termRef, term, fit]); }, [termRef, term, fit]);
useEffect(() => {
switch (action) {
case "renew":
renew();
}
}, [renew, action]);
useEffect(() => { useEffect(() => {
const t = setInterval(() => reloadVmState(), 5000); const t = setInterval(() => reloadVmState(), 5000);
return () => clearInterval(t); return () => clearInterval(t);
}, []) }, []);
if (!state) { if (!state) {
return <h2>No VM selected</h2>; return <h2>No VM selected</h2>;
@ -123,60 +124,31 @@ export default function VmPage() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<VpsInstanceRow vm={state} actions={true} /> <VpsInstanceRow vm={state} actions={true} />
{action === undefined && (
<> <div className="text-xl">Network:</div>
<div className="text-xl">Network:</div> <div className="grid grid-cols-2 gap-4">{networkInfo()}</div>
<div className="grid grid-cols-2 gap-4"> <div className="flex gap-2 items-center">
{networkInfo()} <div className="text-xl">SSH Key:</div>
</div> <div className="text-sm bg-neutral-900 px-3 py-1 rounded-lg">
<div className="flex gap-2 items-center"> {state.ssh_key?.name}
<div className="text-xl">SSH Key:</div> </div>
<div className="text-sm bg-neutral-900 px-3 py-1 rounded-lg"> <Icon name="pencil" onClick={() => setEditKey(true)} />
{state.ssh_key?.name} </div>
</div> <hr />
<Icon name="pencil" onClick={() => setEditKey(true)} /> <div className="flex gap-4">
</div> <AsyncButton onClick={() => navigate("/vm/billing", { state })}>
<hr /> Billing
<div className="text-xl">Renewal</div> </AsyncButton>
<div className="flex justify-between items-center"> </div>
<div>{new Date(state.expires).toDateString()}</div> {/*
{state.template?.cost_plan && (
<div>
<CostLabel cost={state.template?.cost_plan} />
</div>
)}
</div>
<AsyncButton onClick={() => navigate("/vm/renew", { state })}>
Extend Now
</AsyncButton>
{/*
{!term && <AsyncButton onClick={openTerminal}>Connect Terminal</AsyncButton>} {!term && <AsyncButton onClick={openTerminal}>Connect Terminal</AsyncButton>}
{term && <div className="border p-2" ref={termRef}></div>}*/} {term && <div className="border p-2" ref={termRef}></div>}*/}
</>
)}
{action === "renew" && (
<>
<h3>Renew VPS</h3>
{payment && (
<VpsPayment
payment={payment}
onPaid={async () => {
setPayment(undefined);
if (!login?.api || !state) return;
navigate("/vm", {
state
})
}}
/>
)}
</>
)}
{editKey && ( {editKey && (
<Modal id="edit-ssh-key" onClose={() => setEditKey(false)}> <Modal id="edit-ssh-key" onClose={() => setEditKey(false)}>
<SSHKeySelector selectedKey={key} setSelectedKey={setKey} /> <SSHKeySelector selectedKey={key} setSelectedKey={setKey} />
<div className="flex flex-col gap-4 mt-8"> <div className="flex flex-col gap-4 mt-8">
<small>After selecting a new key, please restart the VM.</small> <small>After selecting a new key, please restart the VM.</small>
{error && <b className="text-red-700">{error}</b>} {error && <b className="text-red-500">{error}</b>}
<AsyncButton <AsyncButton
onClick={async () => { onClick={async () => {
setError(undefined); setError(undefined);
@ -201,15 +173,21 @@ export default function VmPage() {
)} )}
{editReverse && ( {editReverse && (
<Modal id="edit-reverse" onClose={() => setEditReverse(undefined)}> <Modal id="edit-reverse" onClose={() => setEditReverse(undefined)}>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="text-lg">Reverse DNS:</div> <div className="text-lg">Reverse DNS:</div>
<input type="text" placeholder="my-domain.com" value={editReverse.reverse_dns} onChange={(e) => setEditReverse({ <input
...editReverse, type="text"
reverse_dns: e.target.value placeholder="my-domain.com"
})} /> value={editReverse.reverse_dns}
onChange={(e) =>
setEditReverse({
...editReverse,
reverse_dns: e.target.value,
})
}
/>
<small>DNS updates can take up to 48hrs to propagate.</small> <small>DNS updates can take up to 48hrs to propagate.</small>
{error && <b className="text-red-700">{error}</b>} {error && <b className="text-red-500">{error}</b>}
<AsyncButton <AsyncButton
onClick={async () => { onClick={async () => {
setError(undefined); setError(undefined);

View File

@ -1,34 +1,37 @@
export interface RefCode { export interface RefCode {
code: string; code: string;
saved: number; saved: number;
} }
export function saveRefCode() { export function saveRefCode() {
const search = new URLSearchParams(window.location.search); const search = new URLSearchParams(window.location.search);
const code = search.get("ref"); const code = search.get("ref");
if (code) { if (code) {
// save or overwrite new code from landing // save or overwrite new code from landing
window.localStorage.setItem("ref", JSON.stringify({ window.localStorage.setItem(
code, "ref",
saved: Math.floor(new Date().getTime() / 1000) JSON.stringify({
})); code,
window.location.search = ""; saved: Math.floor(new Date().getTime() / 1000),
} }),
);
window.location.search = "";
}
} }
export function getRefCode() { export function getRefCode() {
const ref = window.localStorage.getItem("ref"); const ref = window.localStorage.getItem("ref");
if (ref) { if (ref) {
const refObj = JSON.parse(ref) as RefCode; const refObj = JSON.parse(ref) as RefCode;
const now = Math.floor(new Date().getTime() / 1000); const now = Math.floor(new Date().getTime() / 1000);
// treat code as stale if > 7days old // treat code as stale if > 7days old
if (Math.abs(refObj.saved - now) > 604800) { if (Math.abs(refObj.saved - now) > 604800) {
window.localStorage.removeItem("ref"); window.localStorage.removeItem("ref");
}
return refObj;
} }
return refObj;
}
} }
export function clearRefCode() { export function clearRefCode() {
window.localStorage.removeItem("ref"); window.localStorage.removeItem("ref");
} }