feat: show lnurl as payment method
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-05-02 13:59:37 +01:00
parent f28c785cbd
commit 0f8ee33279
4 changed files with 136 additions and 49 deletions

View File

@ -210,7 +210,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>>(

View File

@ -8,6 +8,9 @@ import CostLabel, { CostAmount } from "../components/cost";
import { RevolutPayWidget } from "../components/revolut"; import { RevolutPayWidget } from "../components/revolut";
import { timeValue } from "../utils"; import { timeValue } from "../utils";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { ApiUrl } from "../const";
import QrCode from "../components/qr";
import { LNURL } from "@snort/shared";
export function VmBillingPage() { export function VmBillingPage() {
const location = useLocation() as { state?: VmInstance }; const location = useLocation() as { state?: VmInstance };
@ -48,7 +51,29 @@ export function VmBillingPage() {
const className = const className =
"flex items-center justify-between px-3 py-2 bg-neutral-900 rounded-xl cursor-pointer"; "flex items-center justify-between px-3 py-2 bg-neutral-900 rounded-xl cursor-pointer";
const nameRow = (v: PaymentMethod) => {
return (
<div>
{v.name.toUpperCase()} ({v.currencies.join(",")})
</div>
);
};
switch (v.name) { switch (v.name) {
case "lnurl": {
const addr = v.metadata?.["address"];
return (
<div
key={v.name}
className={className}
onClick={() => {
setMethod(v);
}}
>
{nameRow(v)}
<div>{addr}</div>
</div>
);
}
case "lightning": { case "lightning": {
return ( return (
<div <div
@ -59,9 +84,7 @@ export function VmBillingPage() {
renew(v.name); renew(v.name);
}} }}
> >
<div> {nameRow(v)}
{v.name.toUpperCase()} ({v.currencies.join(",")})
</div>
<div className="rounded-lg p-2 bg-green-800">Pay Now</div> <div className="rounded-lg p-2 bg-green-800">Pay Now</div>
</div> </div>
); );
@ -71,9 +94,7 @@ export function VmBillingPage() {
if (!pkey) return <b>Missing Revolut pubkey</b>; if (!pkey) return <b>Missing Revolut pubkey</b>;
return ( return (
<div key={v.name} className={className}> <div key={v.name} className={className}>
<div> {nameRow(v)}
{v.name.toUpperCase()} ({v.currencies.join(",")})
</div>
{state && ( {state && (
<RevolutPayWidget <RevolutPayWidget
mode={import.meta.env.VITE_REVOLUT_MODE} mode={import.meta.env.VITE_REVOLUT_MODE}
@ -129,6 +150,16 @@ export function VmBillingPage() {
const days = const days =
(expireDate.getTime() - new Date().getTime()) / 1000 / 24 / 60 / 60; (expireDate.getTime() - new Date().getTime()) / 1000 / 24 / 60 / 60;
const lud16 = `${state.id}@${new URL(ApiUrl).host}`;
// Static LNURL payment method
const lnurl = {
name: "lnurl",
currencies: ["BTC"],
metadata: {
address: lud16,
},
} as PaymentMethod;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Link to={"/vm"} state={state}> <Link to={"/vm"} state={state}>
@ -157,7 +188,23 @@ export function VmBillingPage() {
{methods && !method && ( {methods && !method && (
<> <>
<div className="text-xl">Payment Method:</div> <div className="text-xl">Payment Method:</div>
{methods.map((v) => paymentMethod(v))} {[lnurl, ...methods].map((v) => paymentMethod(v))}
</>
)}
{method?.name === "lnurl" && (
<>
<div className="flex flex-col gap-4 rounded-xl p-3 bg-neutral-900 items-center">
<QrCode
data={`lightning:${new LNURL(lud16).lnurl}`}
width={512}
height={512}
avatar="/logo.jpg"
className="cursor-pointer rounded-xl overflow-hidden"
/>
<div className="monospace select-all break-all text-center text-sm">
{lud16}
</div>
</div>
</> </>
)} )}
{payment && ( {payment && (
@ -172,35 +219,69 @@ export function VmBillingPage() {
/> />
</> </>
)} )}
<div className="text-xl">Payment History</div> {!methods && (
<table className="table bg-neutral-900 rounded-xl text-center"> <>
<thead> <div className="text-xl">Payment History</div>
<tr> <table className="table bg-neutral-900 rounded-xl text-center">
<th>Date</th> <thead>
<th>Amount</th> <tr>
<th>Time</th> <th>Date</th>
<th>Status</th> <th>Amount</th>
<th></th> <th>Time</th>
</tr> <th>Status</th>
</thead> <th></th>
<tbody> </tr>
{payments.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()) </thead>
.map(a => <tr key={a.id}> <tbody>
<td className="pl-4">{new Date(a.created).toLocaleString()}</td> {payments
<td><CostAmount cost={{ amount: (a.amount + a.tax) / (a.currency === "BTC" ? 1e11 : 100), currency: a.currency }} converted={false} /></td> .sort(
<td>{timeValue(a.time)}</td> (a, b) =>
<td>{a.is_paid ? "Paid" : (new Date(a.expires) <= new Date() ? "Expired" : "Unpaid")}</td> new Date(b.created).getTime() -
<td> new Date(a.created).getTime(),
{a.is_paid && <div title="Generate Invoice" onClick={async () => { )
const l = await login?.api.invoiceLink(a.id); .map((a) => (
window.open(l, "_blank"); <tr key={a.id}>
}}> <td className="pl-4">
<Icon name="printer" /> {new Date(a.created).toLocaleString()}
</div>} </td>
</td> <td>
</tr>)} <CostAmount
</tbody> cost={{
</table> amount:
(a.amount + a.tax) /
(a.currency === "BTC" ? 1e11 : 100),
currency: a.currency,
}}
converted={false}
/>
</td>
<td>{timeValue(a.time)}</td>
<td>
{a.is_paid
? "Paid"
: new Date(a.expires) <= new Date()
? "Expired"
: "Unpaid"}
</td>
<td>
{a.is_paid && (
<div
title="Generate Invoice"
onClick={async () => {
const l = await login?.api.invoiceLink(a.id);
window.open(l, "_blank");
}}
>
<Icon name="printer" />
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div> </div>
); );
} }

View File

@ -59,9 +59,10 @@ export default function VmPage() {
); );
} }
const hasNoIps = (state?.ip_assignments?.length ?? 0) === 0;
function networkInfo() { function networkInfo() {
if (!state) return; if (!state) return;
if ((state.ip_assignments?.length ?? 0) === 0) { if (hasNoIps) {
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 <>{state.ip_assignments?.map((i) => ipRow(i, true))}</>; return <>{state.ip_assignments?.map((i) => ipRow(i, true))}</>;
@ -86,9 +87,7 @@ export default function VmPage() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Link to={"/account"}> <Link to={"/account"}>&lt; Back</Link>
&lt; Back
</Link>
<VpsInstanceRow vm={state} actions={true} /> <VpsInstanceRow vm={state} actions={true} />
<div className="text-xl">Network:</div> <div className="text-xl">Network:</div>
@ -102,12 +101,14 @@ export default function VmPage() {
</div> </div>
<Icon name="pencil" onClick={() => setEditKey(true)} /> <Icon name="pencil" onClick={() => setEditKey(true)} />
</div> </div>
<div className="bg-neutral-900 px-2 py-3 rounded-lg flex gap-2 items-center"> {!hasNoIps && (
<div>Login:</div> <div className="bg-neutral-900 px-2 py-3 rounded-lg flex gap-2 items-center">
<pre className="select-all bg-neutral-800 px-3 py-1 rounded-full"> <div>Login:</div>
ssh {state.image.default_username}@{bestHost()} <pre className="select-all bg-neutral-800 px-3 py-1 rounded-full">
</pre> ssh {state.image.default_username}@{bestHost()}
</div> </pre>
</div>
)}
</div> </div>
<hr /> <hr />
<div className="flex gap-4"> <div className="flex gap-4">

View File

@ -64,7 +64,12 @@ export function timeValue(n: number): string {
if (n >= 3600) { if (n >= 3600) {
const hours = Math.floor(n / 3600); const hours = Math.floor(n / 3600);
const minutes = Math.floor((n % 3600) / 60); const minutes = Math.floor((n % 3600) / 60);
return hours + " hr" + (hours !== 1 ? "s" : "") + (minutes > 0 ? " " + minutes + " min" + (minutes !== 1 ? "s" : "") : ""); return (
hours +
" hr" +
(hours !== 1 ? "s" : "") +
(minutes > 0 ? " " + minutes + " min" + (minutes !== 1 ? "s" : "") : "")
);
} }
if (n >= 60) { if (n >= 60) {
const minutes = Math.floor(n / 60); const minutes = Math.floor(n / 60);