Compare commits

...

10 Commits

Author SHA1 Message Date
26d36adbeb feat: taxes
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
ref: LNVPS/api#18
2025-03-11 15:59:32 +00:00
c1312d97f1 feat: revolut pay
All checks were successful
continuous-integration/drone/push Build is passing
ref: LNVPS/api#24
2025-03-11 12:42:16 +00:00
57cc619b8c feat: alt prices
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-10 15:10:47 +00:00
7cba506d6b feat: latest news on homepage
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-06 22:32:09 +00:00
8e3e4c0364 feat: custom pricing
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-06 21:43:31 +00:00
1aab7c9372 feat: news page
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-06 11:00:05 +00:00
0b93b0d4f9 fix: graph label
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-05 16:37:14 +00:00
c6e4a9e3c9 chore: formatting
Some checks failed
continuous-integration/drone/push Build is failing
2025-03-05 16:26:47 +00:00
7ba2659fbf feat: graphs 2025-03-05 16:26:04 +00:00
b52735a0a4 feat: new billing page 2025-03-05 15:33:32 +00:00
27 changed files with 1452 additions and 286 deletions

View File

@ -1 +1,2 @@
#VITE_API_URL="http://localhost:8000"
VITE_API_URL="http://localhost:8000"
VITE_REVOLUT_MODE="sandbox"

View File

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@revolut/checkout": "^1.1.20",
"@scure/base": "^1.2.1",
"@snort/shared": "^1.0.17",
"@snort/system": "^1.6.1",
@ -19,11 +20,13 @@
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"classnames": "^2.5.1",
"iso-3166-1": "^2.1.1",
"marked": "^15.0.7",
"qr-code-styling": "^1.8.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.0.1"
"react-router-dom": "^7.0.1",
"recharts": "^2.15.1"
},
"devDependencies": {
"@eslint/js": "^9.8.0",

View File

@ -9,17 +9,29 @@ export type ApiResponse<T> = ApiResponseBase & {
data: T;
};
export enum DiskType {
SSD = "ssd",
HDD = "hdd",
}
export enum DiskInterface {
SATA = "sata",
SCSI = "scsi",
PCIe = "pcid",
}
export interface AccountDetail {
email?: string;
contact_nip17: boolean;
contact_email: boolean;
country_code: string;
}
export interface VmCostPlan {
id: number;
name: string;
amount: number;
currency: "EUR" | "BTC";
currency: string;
interval_amount: number;
interval_type: string;
}
@ -29,16 +41,54 @@ export interface VmHostRegion {
name: string;
}
export interface VmCustomTemplateParams {
id: number;
name: string;
region: VmHostRegion;
max_cpu: number;
min_cpu: number;
min_memory: number;
max_memory: number;
min_disk: number;
max_disk: number;
disks: Array<VmCustomTemplateDiskParams>;
}
export interface VmCustomTemplateDiskParams {
disk_type: DiskType;
disk_interface: DiskInterface;
}
export interface VmCustomTemplateRequest {
pricing_id: number;
cpu: number;
memory: number;
disk: number;
disk_type: DiskType;
disk_interface: DiskInterface;
}
export interface VmCustomPrice {
currency: string;
amount: number;
}
export interface VmTemplateResponse {
templates: Array<VmTemplate>;
custom_template?: Array<VmCustomTemplateParams>;
}
export interface VmTemplate {
id: number;
pricing_id?: number;
name: string;
created: Date;
expires?: Date;
cpu: number;
memory: number;
disk_size: number;
disk_type: string;
disk_interface: string;
disk_type: DiskType;
disk_interface: DiskInterface;
cost_plan: VmCostPlan;
region: VmHostRegion;
}
@ -89,11 +139,17 @@ export interface UserSshKey {
export interface VmPayment {
id: string;
invoice: string;
created: string;
expires: string;
amount: number;
tax: number;
is_paid: boolean;
data: {
lightning?: string;
revolut?: {
token: string;
};
};
}
export interface PatchVm {
@ -101,11 +157,28 @@ export interface PatchVm {
reverse_dns?: string;
}
export interface TimeSeriesData {
timestamp: number;
cpu: number;
memory: number;
memory_size: number;
net_in: number;
net_out: number;
disk_write: number;
disk_read: number;
}
export interface PaymentMethod {
name: string;
currencies: Array<string>;
metadata?: Record<string, string>;
}
export class LNVpsApi {
constructor(
readonly url: string,
readonly publisher: EventPublisher | undefined,
) { }
) {}
async getAccount() {
const { data } = await this.#handleResponse<ApiResponse<AccountDetail>>(
@ -135,6 +208,13 @@ export class LNVpsApi {
return data;
}
async getVmTimeSeries(id: number) {
const { data } = await this.#handleResponse<
ApiResponse<Array<TimeSeriesData>>
>(await this.#req(`/api/v1/vm/${id}/time-series`, "GET"));
return data;
}
async patchVm(id: number, req: PatchVm) {
const { data } = await this.#handleResponse<ApiResponse<void>>(
await this.#req(`/api/v1/vm/${id}`, "PATCH", req),
@ -157,9 +237,9 @@ export class LNVpsApi {
}
async listOffers() {
const { data } = await this.#handleResponse<ApiResponse<Array<VmTemplate>>>(
await this.#req("/api/v1/vm/templates", "GET"),
);
const { data } = await this.#handleResponse<
ApiResponse<VmTemplateResponse>
>(await this.#req("/api/v1/vm/templates", "GET"));
return data;
}
@ -187,21 +267,50 @@ export class LNVpsApi {
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>>(
await this.#req("/api/v1/vm", "POST", {
template_id,
image_id,
ssh_key_id,
ref_code
ref_code,
}),
);
return data;
}
async renewVm(vm_id: number) {
async customPrice(req: VmCustomTemplateRequest) {
const { data } = await this.#handleResponse<ApiResponse<VmCustomPrice>>(
await this.#req("/api/v1/vm/custom-template/price", "POST", req),
);
return data;
}
async orderCustom(
req: VmCustomTemplateRequest,
image_id: number,
ssh_key_id: number,
ref_code?: string,
) {
const { data } = await this.#handleResponse<ApiResponse<VmInstance>>(
await this.#req("/api/v1/vm/custom-template", "POST", {
...req,
image_id,
ssh_key_id,
ref_code,
}),
);
return data;
}
async renewVm(vm_id: number, method: string) {
const { data } = await this.#handleResponse<ApiResponse<VmPayment>>(
await this.#req(`/api/v1/vm/${vm_id}/renew`, "GET"),
await this.#req(`/api/v1/vm/${vm_id}/renew?method=${method}`, "GET"),
);
return data;
}
@ -213,6 +322,13 @@ export class LNVpsApi {
return data;
}
async getPaymentMethods() {
const { data } = await this.#handleResponse<
ApiResponse<Array<PaymentMethod>>
>(await this.#req("/api/v1/payment/methods", "GET"));
return data;
}
async connect_terminal(id: number) {
const u = `${this.url}/api/v1/console/${id}`;
const auth = await this.#auth_event(u, "GET");

View File

@ -22,7 +22,7 @@ const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>(
}
}}
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":
!hasBg && props.disabled === true,

View File

@ -1,6 +1,16 @@
import { VmCostPlan } from "../api";
interface Price {
currency: string;
amount: number;
}
type Cost = Price & { interval_type?: string; other_price?: Array<Price> };
export default function CostLabel({ cost }: { cost: VmCostPlan }) {
export default function CostLabel({
cost,
converted,
}: {
cost: Cost;
converted?: boolean;
}) {
function intervalName(n: string) {
switch (n) {
case "day":
@ -13,8 +23,19 @@ export default function CostLabel({ cost }: { cost: VmCostPlan }) {
}
return (
<>
{cost.amount} {cost.currency}/{intervalName(cost.interval_type)}
</>
<div>
{converted && "~"}
{cost.currency !== "BTC"
? cost.amount.toFixed(2)
: Math.floor(cost.amount * 1e8).toLocaleString()}{" "}
{cost.currency === "BTC" ? "sats" : cost.currency}
{cost.interval_type && <>/{intervalName(cost.interval_type)}</>}
{cost.other_price &&
cost.other_price.map((a) => (
<div key={a.currency} className="text-xs">
<CostLabel cost={a} converted={true} />
</div>
))}
</div>
);
}

View File

@ -0,0 +1,24 @@
import { EventKind, RequestBuilder } from "@snort/system";
import { NostrProfile } from "../const";
import { useRequestBuilder } from "@snort/system-react";
import { NewLink } from "./news-link";
export function LatestNews() {
const req = new RequestBuilder("latest-news");
req
.withFilter()
.kinds([EventKind.LongFormTextNote])
.authors([NostrProfile.id])
.limit(1);
const posts = useRequestBuilder(req);
if (posts.length > 0) {
return (
<div className="flex flex-col gap-2">
<div className="text-xl">Latest News</div>
<NewLink ev={posts[0]} />
</div>
);
}
}

View File

@ -1,39 +0,0 @@
.markdown a {
@apply underline;
}
.markdown blockquote {
margin: 0;
padding-left: 12px;
@apply border-l-neutral-800 border-2 text-neutral-400;
}
.markdown hr {
border: 0;
height: 1px;
margin: 20px;
@apply bg-neutral-800;
}
.markdown img:not(.custom-emoji),
.markdown video,
.markdown iframe,
.markdown audio {
max-width: 100%;
display: block;
}
.markdown iframe,
.markdown video {
width: -webkit-fill-available;
aspect-ratio: 16 / 9;
}
.markdown h1,
.markdown h2,
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
margin: 0.5em 0;
}

View File

@ -1,5 +1,3 @@
import "./markdown.css";
import { ReactNode, forwardRef, useMemo } from "react";
import { Token, Tokens, marked } from "marked";
import { Link } from "react-router-dom";
@ -16,9 +14,9 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
switch (t.type) {
case "paragraph": {
return (
<div key={ctr++}>
<p key={ctr++} className="py-2">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</div>
</p>
);
}
case "image": {
@ -28,37 +26,37 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
switch (t.depth) {
case 1:
return (
<h1 key={ctr++}>
<h1 key={ctr++} className="my-6 text-2xl">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h1>
);
case 2:
return (
<h2 key={ctr++}>
<h2 key={ctr++} className="my-5 text-xl">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h2>
);
case 3:
return (
<h3 key={ctr++}>
<h3 key={ctr++} className="my-4 text-lg">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h3>
);
case 4:
return (
<h4 key={ctr++}>
<h4 key={ctr++} className="my-3 text-md">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h4>
);
case 5:
return (
<h5 key={ctr++}>
<h5 key={ctr++} className="my-2">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h5>
);
case 6:
return (
<h6 key={ctr++}>
<h6 key={ctr++} className="my-2">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</h6>
);
@ -66,7 +64,11 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
throw new Error("Invalid heading");
}
case "codespan": {
return <code key={ctr++}>{t.raw}</code>;
return (
<code key={ctr++} className="bg-neutral-900 px-2">
{t.raw.substring(1, t.raw.length - 1)}
</code>
);
}
case "code": {
return <pre key={ctr++}>{t.raw}</pre>;
@ -84,23 +86,34 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
}
case "blockquote": {
return (
<blockquote key={ctr++}>
<blockquote
key={ctr++}
className="outline-l-neutral-900 outline text-neutral-300 p-3"
>
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</blockquote>
);
}
case "link": {
return (
<Link to={t.href} key={ctr++}>
<Link to={t.href} key={ctr++} className="underline">
{t.tokens ? t.tokens.map(renderToken) : t.raw}
</Link>
);
}
case "list": {
if (t.ordered) {
return <ol key={ctr++}>{t.items.map(renderToken)}</ol>;
return (
<ol key={ctr++} className="list-decimal list-outside">
{t.items.map(renderToken)}
</ol>
);
} else {
return <ul key={ctr++}>{t.items.map(renderToken)}</ul>;
return (
<ul key={ctr++} className="list-disc list-outside">
{t.items.map(renderToken)}
</ul>
);
}
}
case "list_item": {
@ -170,7 +183,7 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
return marked.lexer(props.content);
}, [props.content]);
return (
<div className="markdown" ref={ref}>
<div className="leading-8 text-pretty break-words" ref={ref}>
{parsed
.filter((a) => a.type !== "footnote" && a.type !== "footnotes")
.map((a) => renderToken(a))}

View File

@ -0,0 +1,26 @@
import { NostrEvent, NostrLink } from "@snort/system";
import { Link } from "react-router-dom";
export function NewLink({ ev }: { ev: NostrEvent }) {
const link = NostrLink.fromEvent(ev);
const title = ev.tags.find((a) => a[0] == "title")?.[1];
const posted = Number(
ev.tags.find((a) => a[0] == "published_at")?.[1] ?? ev.created_at,
);
const slug = title
?.toLocaleLowerCase()
.replace(/[:/]/g, "")
.trimStart()
.trimEnd()
.replace(/ /g, "-");
return (
<Link to={`/news/${slug}`} state={ev} key={link.tagKey}>
<div className="flex flex-col rounded-xl bg-neutral-900 px-3 py-4">
<div className="text-xl flex items-center justify-between">
<div>{title}</div>
<div>{new Date(posted * 1000).toDateString()}</div>
</div>
</div>
</Link>
);
}

View File

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

View File

@ -0,0 +1,65 @@
import RevolutCheckout, { Mode } from "@revolut/checkout";
import { useEffect, useRef } from "react";
import { VmCostPlan } from "../api";
interface RevolutProps {
amount: VmCostPlan | {
amount: number;
currency: string;
tax?: number;
};
pubkey: string;
loadOrder: () => Promise<string>;
onPaid: () => void;
onCancel?: () => void;
mode?: string;
}
export function RevolutPayWidget({
pubkey,
loadOrder,
amount,
onPaid,
onCancel,
mode,
}: RevolutProps) {
const ref = useRef<HTMLDivElement | null>(null);
async function load(pubkey: string, ref: HTMLDivElement) {
const { revolutPay } = await RevolutCheckout.payments({
locale: "auto",
mode: (mode ?? "prod") as Mode,
publicToken: pubkey,
});
ref.innerHTML = "";
revolutPay.mount(ref, {
currency: amount.currency,
totalAmount: amount.amount,
createOrder: async () => {
const id = await loadOrder();
return {
publicId: id,
};
},
buttonStyle: {
cashback: false,
},
});
revolutPay.on("payment", (payload) => {
console.debug(payload);
if (payload.type === "success") {
onPaid();
}
if (payload.type === "cancel") {
onCancel?.();
}
});
}
useEffect(() => {
if (ref.current) {
load(pubkey, ref.current);
}
}, [pubkey, ref]);
return <div ref={ref}></div>;
}

View File

@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import {
DiskInterface,
DiskType,
LNVpsApi,
VmCustomPrice,
VmCustomTemplateParams,
} from "../api";
import { ApiUrl, GiB } from "../const";
import CostLabel from "./cost";
import VpsPayButton from "./pay-button";
export function VpsCustomOrder({
templates,
}: {
templates: Array<VmCustomTemplateParams>;
}) {
const [region] = useState(templates.at(0)?.region.id);
const params = templates.find((t) => t.region.id == region) ?? templates[0];
const [cpu, setCpu] = useState(params.min_cpu ?? 1);
const [ram, setRam] = useState(Math.floor((params.min_memory ?? GiB) / GiB));
const [disk, setDisk] = useState(Math.floor((params.min_disk ?? GiB) / GiB));
const [diskType] = useState(params.disks.at(0));
const [price, setPrice] = useState<VmCustomPrice>();
const cost_plan = {
id: 0,
name: "custom",
amount: price?.amount ?? 0,
currency: price?.currency ?? "",
interval_amount: 1,
interval_type: "month",
};
useEffect(() => {
const t = setTimeout(() => {
const api = new LNVpsApi(ApiUrl, undefined);
api
.customPrice({
pricing_id: params.id,
cpu,
memory: ram * GiB,
disk: disk * GiB,
disk_type: diskType?.disk_type ?? DiskType.SSD,
disk_interface: diskType?.disk_interface ?? DiskInterface.PCIe,
})
.then(setPrice);
}, 500);
return () => clearTimeout(t);
}, [region, cpu, ram, disk, diskType, params]);
if (templates.length == 0) return;
return (
<div className="flex flex-col gap-4 bg-neutral-900 rounded-xl px-4 py-6">
<div className="text-lg">Custom VPS Order</div>
<div className="flex items-center gap-4">
<div className="min-w-[100px]">{cpu} CPU</div>
<input
type="range"
value={cpu}
onChange={(e) => setCpu(e.target.valueAsNumber)}
min={params.min_cpu}
max={params.max_cpu}
step={1}
className="grow"
/>
</div>
<div className="flex items-center gap-4">
<div className="min-w-[100px]">{ram.toString()} GB RAM</div>
<input
type="range"
value={ram}
onChange={(e) => setRam(e.target.valueAsNumber)}
min={Math.floor(params.min_memory / GiB)}
max={Math.floor(params.max_memory / GiB)}
step={1}
className="grow"
/>
</div>
<div className="flex items-center gap-4">
<div className="min-w-[100px]">
{disk.toString()} GB {diskType?.disk_type.toLocaleUpperCase()}
</div>
<input
type="range"
value={disk}
onChange={(e) => setDisk(e.target.valueAsNumber)}
min={Math.floor(params.min_disk / GiB)}
max={Math.floor(params.max_disk / GiB)}
step={1}
className="grow"
/>
</div>
{price && (
<div className="flex items-center justify-between">
<div className="text-xl flex-1">
<CostLabel cost={cost_plan} />
</div>
<div className="flex-1">
<VpsPayButton
spec={{
id: 0,
pricing_id: params.id,
cpu,
name: "Custom",
memory: ram * GiB,
disk_size: disk * GiB,
disk_type: diskType?.disk_type ?? DiskType.SSD,
disk_interface: diskType?.disk_interface ?? DiskInterface.PCIe,
created: new Date(),
region: params.region,
cost_plan,
}}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -41,7 +41,12 @@ export default function VpsInstanceRow({
<div className="flex gap-2 items-center">
{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
</Link>
</>

View File

@ -11,7 +11,8 @@ export default function VpsPayment({
onPaid?: () => void;
}) {
const login = useLogin();
const ln = `lightning:${payment.invoice}`;
const invoice = payment.data.lightning;
const ln = `lightning:${invoice}`;
async function checkPayment(api: LNVpsApi) {
try {
@ -47,8 +48,15 @@ export default function VpsPayment({
avatar="/logo.jpg"
className="cursor-pointer rounded-xl overflow-hidden"
/>
{(payment.amount / 1000).toLocaleString()} sats
<div className="monospace select-all break-all text-center text-sm">{payment.invoice}</div>
<div className="flex flex-col items-center">
<div>{((payment.amount + payment.tax) / 1000).toLocaleString()} sats</div>
{payment.tax > 0 && <div className="text-xs">
including {(payment.tax / 1000).toLocaleString()} sats tax
</div>}
</div>
<div className="monospace select-all break-all text-center text-sm">
{invoice}
</div>
</div>
);
}

View File

@ -13,6 +13,10 @@ import SignUpPage from "./pages/sign-up.tsx";
import { TosPage } from "./pages/terms.tsx";
import { StatusPage } from "./pages/status.tsx";
import { AccountSettings } from "./pages/account-settings.tsx";
import { VmBillingPage } from "./pages/vm-billing.tsx";
import { VmGraphsPage } from "./pages/vm-graphs.tsx";
import { NewsPage } from "./pages/news.tsx";
import { NewsPost } from "./pages/news-post.tsx";
const system = new NostrSystem({
automaticOutboxModel: false,
@ -50,9 +54,17 @@ const router = createBrowserRouter([
element: <OrderPage />,
},
{
path: "/vm/:action?",
path: "/vm",
element: <VmPage />,
},
{
path: "/vm/billing/:action?",
element: <VmBillingPage />,
},
{
path: "/vm/graphs",
element: <VmGraphsPage />,
},
{
path: "/tos",
element: <TosPage />,
@ -61,6 +73,14 @@ const router = createBrowserRouter([
path: "/status",
element: <StatusPage />,
},
{
path: "/news",
element: <NewsPage />,
},
{
path: "/news/:id",
element: <NewsPost />,
},
],
},
]);

View File

@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { AccountDetail } from "../api";
import { AsyncButton } from "../components/button";
import { Icon } from "../components/icon";
import { default as iso } from "iso-3166-1";
export function AccountSettings() {
const login = useLogin();
@ -13,63 +14,73 @@ export function AccountSettings() {
login?.api.getAccount().then(setAcc);
}, [login]);
function notifications() {
return (
<>
<h3>Notification Settings</h3>
<div className="flex gap-2 items-center">
<input
type="checkbox"
checked={acc?.contact_email ?? false}
onChange={(e) => {
setAcc((s) =>
s ? { ...s, contact_email: e.target.checked } : undefined,
);
}}
/>
Email
<input
type="checkbox"
checked={acc?.contact_nip17 ?? false}
onChange={(e) => {
setAcc((s) =>
s ? { ...s, contact_nip17: e.target.checked } : undefined,
);
}}
/>
Nostr DM
</div>
<div className="flex gap-2 items-center">
<h4>Email</h4>
<input
type="text"
disabled={!editEmail}
value={acc?.email}
onChange={(e) =>
setAcc((s) => (s ? { ...s, email: e.target.value } : undefined))
}
/>
{!editEmail && (
<Icon name="pencil" onClick={() => setEditEmail(true)} />
)}
</div>
<div>
<AsyncButton
onClick={async () => {
if (login?.api && acc) {
await login.api.updateAccount(acc);
const newAcc = await login.api.getAccount();
setAcc(newAcc);
setEditEmail(false);
}
}}
>
Save
</AsyncButton>
</div>
</>
);
}
if (!acc) return;
return <div className="flex flex-col gap-4">
<h3>
Account Settings
</h3>
return <>{notifications()}</>;
<div className="flex gap-2 items-center">
<h4>Country</h4>
<select value={acc?.country_code}
onChange={(e) =>
setAcc((s) => (s ? { ...s, country_code: e.target.value } : undefined))
}
>
{iso.all().map(c => <option value={c.alpha3}>{c.country}</option>)}
</select>
</div>
<h3>Notification Settings</h3>
<div className="flex gap-2 items-center">
<input
type="checkbox"
checked={acc?.contact_email ?? false}
onChange={(e) => {
setAcc((s) =>
s ? { ...s, contact_email: e.target.checked } : undefined,
);
}}
/>
Email
<input
type="checkbox"
checked={acc?.contact_nip17 ?? false}
onChange={(e) => {
setAcc((s) =>
s ? { ...s, contact_nip17: e.target.checked } : undefined,
);
}}
/>
Nostr DM
</div>
<div className="flex gap-2 items-center">
<h4>Email</h4>
<input
type="text"
disabled={!editEmail}
value={acc?.email}
onChange={(e) =>
setAcc((s) => (s ? { ...s, email: e.target.value } : undefined))
}
/>
{!editEmail && (
<Icon name="pencil" onClick={() => setEditEmail(true)} />
)}
</div>
<div>
<AsyncButton
onClick={async () => {
if (login?.api && acc) {
await login.api.updateAccount(acc);
const newAcc = await login.api.getAccount();
setAcc(newAcc);
setEditEmail(false);
}
}}
>
Save
</AsyncButton>
</div>
</div>
}

View File

@ -1,11 +1,13 @@
import { useState, useEffect } from "react";
import { VmTemplate, LNVpsApi } from "../api";
import { LNVpsApi, VmTemplateResponse } from "../api";
import VpsCard from "../components/vps-card";
import { ApiUrl, NostrProfile } from "../const";
import { Link } from "react-router-dom";
import { VpsCustomOrder } from "../components/vps-custom";
import { LatestNews } from "../components/latest-news";
export default function HomePage() {
const [offers, setOffers] = useState<Array<VmTemplate>>();
const [offers, setOffers] = useState<VmTemplateResponse>();
useEffect(() => {
const api = new LNVpsApi(ApiUrl, undefined);
@ -15,21 +17,25 @@ export default function HomePage() {
return (
<>
<div className="flex flex-col gap-4">
<LatestNews />
<div className="text-2xl">VPS Offers</div>
<div>
Virtual Private Server hosting with flexible plans, high uptime, and
dedicated support, tailored to your needs.
</div>
<div className="grid grid-cols-3 gap-2">
{offers?.map((a) => (
<VpsCard spec={a} key={a.id} />
))}
{offers !== undefined && offers.length === 0 &&
<div className="text-red-500 bold text-xl uppercase">No offers available</div>}
{offers?.templates.map((a) => <VpsCard spec={a} key={a.id} />)}
{offers?.templates !== undefined && offers.templates.length === 0 && (
<div className="text-red-500 bold text-xl uppercase">
No offers available
</div>
)}
</div>
<small className="text-neutral-400">
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic
{offers?.custom_template && (
<VpsCustomOrder templates={offers.custom_template} />
)}
<small className="text-neutral-400 text-center">
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic, all prices are excluding taxes.
</small>
<div className="flex flex-col gap-6">
<div className="text-center">
@ -39,6 +45,8 @@ export default function HomePage() {
{" | "}
<Link to="/tos">Terms</Link>
{" | "}
<Link to="/news">News</Link>
{" | "}
<a
href={`https://snort.social/${NostrProfile.encode()}`}
target="_blank"

View File

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

25
src/pages/news-post.tsx Normal file
View File

@ -0,0 +1,25 @@
import { NostrLink, TaggedNostrEvent } from "@snort/system";
import { useLocation } from "react-router-dom";
import Markdown from "../components/markdown";
import Profile from "../components/profile";
export function NewsPost() {
const { state } = useLocation() as { state?: TaggedNostrEvent };
if (!state) return;
const title = state.tags.find((a) => a[0] == "title")?.[1];
const posted = Number(
state.tags.find((a) => a[0] == "published_at")?.[1] ?? state.created_at,
);
return (
<div>
<div className="text-2xl">{title}</div>
<div className="flex items-center justify-between py-8">
<Profile link={NostrLink.profile(state.pubkey, state.relays)} />
<div>{new Date(posted * 1000).toLocaleString()}</div>
</div>
<Markdown content={state.content} />
</div>
);
}

35
src/pages/news.tsx Normal file
View File

@ -0,0 +1,35 @@
import { EventKind, RequestBuilder } from "@snort/system";
import { NostrProfile } from "../const";
import { useRequestBuilder } from "@snort/system-react";
import { NewLink } from "../components/news-link";
export function NewsPage() {
const req = new RequestBuilder("news");
req
.withFilter()
.kinds([EventKind.LongFormTextNote])
.authors([NostrProfile.id])
.limit(10);
const posts = useRequestBuilder(req);
return (
<div className="flex flex-col gap-4">
<div className="text-2xl">News</div>
{posts
.sort((a, b) => {
const a_posted = Number(
a.tags.find((a) => a[0] == "published_at")?.[1] ?? a.created_at,
);
const b_posted = Number(
b.tags.find((z) => z[0] == "published_at")?.[1] ?? b.created_at,
);
return b_posted - a_posted;
})
.map((a) => (
<NewLink ev={a} />
))}
{posts.length === 0 && <div>No posts yet..</div>}
</div>
);
}

View File

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

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

@ -0,0 +1,164 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { PaymentMethod, 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";
import { RevolutPayWidget } from "../components/revolut";
export function VmBillingPage() {
const location = useLocation() as { state?: VmInstance };
const params = useParams();
const login = useLogin();
const navigate = useNavigate();
const [methods, setMethods] = useState<Array<PaymentMethod>>();
const [method, setMethod] = useState<PaymentMethod>();
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);
setMethod(undefined);
setMethods(undefined);
return newState;
}
async function onPaid() {
setMethod(undefined);
setMethods(undefined);
const s = reloadVmState();
if (params["action"] === "renew") {
navigate("/vm", { state: s });
}
}
function paymentMethod(v: PaymentMethod) {
const className =
"flex items-center justify-between px-3 py-2 bg-neutral-900 rounded-xl cursor-pointer";
switch (v.name) {
case "lightning": {
return (
<div
key={v.name}
className={className}
onClick={() => {
setMethod(v);
renew(v.name);
}}
>
<div>
{v.name.toUpperCase()} ({v.currencies.join(",")})
</div>
<div className="rounded-lg p-2 bg-green-800">Pay Now</div>
</div>
);
}
case "revolut": {
const pkey = v.metadata?.["pubkey"];
if (!pkey) return <b>Missing Revolut pubkey</b>;
return (
<div key={v.name} className={className}>
<div>
{v.name.toUpperCase()} ({v.currencies.join(",")})
</div>
{state && (
<RevolutPayWidget
mode={import.meta.env.VITE_REVOLUT_MODE}
pubkey={pkey}
amount={state.template.cost_plan}
onPaid={() => {
onPaid();
}}
loadOrder={async () => {
if (!login?.api || !state) {
throw new Error("Not logged in");
}
const p = await login.api.renewVm(state.id, v.name);
return p.data.revolut!.token;
}}
/>
)}
</div>
);
}
}
}
const loadPaymentMethods = useCallback(
async function () {
if (!login?.api || !state) return;
const p = await login?.api.getPaymentMethods();
setMethods(p);
},
[login?.api, state],
);
const renew = useCallback(
async function (m: string) {
if (!login?.api || !state) return;
const p = await login?.api.renewVm(state.id, m);
setPayment(p);
},
[login?.api, state],
);
useEffect(() => {
if (params["action"] === "renew" && login && state) {
loadPaymentMethods();
}
}, [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>
<div>
<CostLabel cost={state.template.cost_plan} />
<span className="text-sm">ex. tax</span>
</div>
</div>
{days > 0 && (
<div>
Expires: {expireDate.toDateString()} ({Math.floor(days)} days)
</div>
)}
{days < 0 && !methods && (
<div className="text-red-500 text-xl">Expired</div>
)}
{!methods && (
<div>
<AsyncButton onClick={loadPaymentMethods}>Extend Now</AsyncButton>
</div>
)}
{methods && !method && (
<>
<div className="text-xl">Payment Method:</div>
{methods.map((v) => paymentMethod(v))}
</>
)}
{payment && (
<>
<h3>Renew VPS</h3>
<VpsPayment
payment={payment}
onPaid={async () => {
setPayment(undefined);
onPaid();
}}
/>
</>
)}
</div>
);
}

185
src/pages/vm-graphs.tsx Normal file
View File

@ -0,0 +1,185 @@
import { Link, useLocation } from "react-router-dom";
import { TimeSeriesData, VmInstance } from "../api";
import useLogin from "../hooks/login";
import { useEffect, useState } from "react";
import {
ResponsiveContainer,
XAxis,
YAxis,
Tooltip,
LineChart,
Line,
Legend,
} from "recharts";
export function VmGraphsPage() {
const { state } = useLocation() as { state?: VmInstance };
const login = useLogin();
const [data, setData] = useState<Array<TimeSeriesData>>();
useEffect(() => {
if (!state) return;
login?.api.getVmTimeSeries(state.id).then(setData);
}, [login]);
const maxRam =
data?.reduce((acc, v) => {
const mb = v.memory_size / 1024 / 1024;
return acc < mb ? mb : acc;
}, 0) ?? 0;
const KB = 1024;
const MB = 1024 * 1024;
function scaleLabel(v: number) {
switch (v) {
case MB:
return "MiB";
case KB:
return "KiB";
}
return "B";
}
const net_scale =
data?.reduce((acc, v) => {
const b = Math.max(v.net_in, v.net_out);
if (b > MB && b > acc) {
return MB;
} else if (b > KB && b > acc) {
return KB;
} else {
return acc;
}
}, 0) ?? 0;
const net_scale_label = scaleLabel(net_scale);
const disk_scale =
data?.reduce((acc, v) => {
const b = Math.max(v.disk_read, v.disk_write);
if (b > MB && b > acc) {
return MB;
} else if (b > KB && b > acc) {
return KB;
} else {
return acc;
}
}, 0) ?? 0;
const disk_scale_label = scaleLabel(disk_scale);
const sortedData = (data ?? [])
.sort((a, b) => a.timestamp - b.timestamp)
.map((v) => ({
timestamp: new Date(v.timestamp * 1000).toLocaleTimeString(),
CPU: 100 * v.cpu,
RAM: v.memory / 1024 / 1024,
NET_IN: v.net_in / net_scale,
NET_OUT: v.net_out / net_scale,
DISK_READ: v.disk_read / disk_scale,
DISK_WRITE: v.disk_write / disk_scale,
}));
const toolTip = (
<Tooltip
cursor={{ fill: "rgba(200,200,200,0.5)" }}
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload as TimeSeriesData;
return (
<div className="flex flex-col gap-2 bg-neutral-700 rounded-xl px-2 py-3">
<div>{data.timestamp}</div>
{payload.map((p) => (
<div>
{p.name}: {Number(p.value).toFixed(2)}
{p.unit}
</div>
))}
</div>
);
}
}}
/>
);
return (
<div className="flex flex-col gap-4">
<Link to={"/vm"} state={state}>
&lt; Back
</Link>
<h2>CPU</h2>
<ResponsiveContainer height={200}>
<LineChart
data={sortedData}
margin={{ left: 0, right: 0 }}
style={{ userSelect: "none" }}
>
<XAxis dataKey="timestamp" />
<YAxis unit="%" domain={[0, 100]} />
<Line type="monotone" dataKey="CPU" unit="%" dot={false} />
{toolTip}
</LineChart>
</ResponsiveContainer>
<h2>Memory</h2>
<ResponsiveContainer height={200}>
<LineChart
data={sortedData}
margin={{ left: 0, right: 0 }}
style={{ userSelect: "none" }}
>
<XAxis dataKey="timestamp" />
<YAxis unit="MB" domain={[0, maxRam]} />
<Line type="monotone" dataKey="RAM" unit="MB" dot={false} />
{toolTip}
</LineChart>
</ResponsiveContainer>
<h2>Network</h2>
<ResponsiveContainer height={200}>
<LineChart
data={sortedData}
margin={{ left: 20, right: 0 }}
style={{ userSelect: "none" }}
>
<XAxis dataKey="timestamp" />
<YAxis unit={`${net_scale_label}/s`} domain={[0, "auto"]} />
<Line
type="monotone"
dataKey="NET_IN"
unit={`${net_scale_label}/s`}
stroke="red"
dot={false}
/>
<Line
type="monotone"
dataKey="NET_OUT"
unit={`${net_scale_label}/s`}
stroke="green"
dot={false}
/>
{toolTip}
<Legend />
</LineChart>
</ResponsiveContainer>
<h2>Disk</h2>
<ResponsiveContainer height={200}>
<LineChart
data={sortedData}
margin={{ left: 20, right: 0 }}
style={{ userSelect: "none" }}
>
<XAxis dataKey="timestamp" />
<YAxis unit={`${disk_scale_label}/s`} domain={[0, "auto"]} />
<Line
type="monotone"
dataKey="DISK_READ"
unit={`${disk_scale_label}/s`}
stroke="red"
dot={false}
/>
<Line
type="monotone"
dataKey="DISK_WRITE"
unit={`${disk_scale_label}/s`}
stroke="green"
dot={false}
/>
{toolTip}
<Legend />
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

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

View File

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

View File

@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

362
yarn.lock
View File

@ -204,6 +204,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7":
version: 7.26.9
resolution: "@babel/runtime@npm:7.26.9"
dependencies:
regenerator-runtime: "npm:^0.14.0"
checksum: 10c0/e8517131110a6ec3a7360881438b85060e49824e007f4a64b5dfa9192cf2bb5c01e84bfc109f02d822c7edb0db926928dd6b991e3ee460b483fb0fac43152d9b
languageName: node
linkType: hard
"@babel/template@npm:^7.25.0":
version: 7.25.0
resolution: "@babel/template@npm:7.25.0"
@ -611,6 +620,13 @@ __metadata:
languageName: node
linkType: hard
"@revolut/checkout@npm:^1.1.20":
version: 1.1.20
resolution: "@revolut/checkout@npm:1.1.20"
checksum: 10c0/8c1053a434ff759a4101f757e08a341331993e4be210fdd6c5def7e452ead9710dba300efe30fd8570a6686bd445ecd15716f5a5c93996a6d60713946e6317a0
languageName: node
linkType: hard
"@rollup/rollup-android-arm-eabi@npm:4.20.0":
version: 4.20.0
resolution: "@rollup/rollup-android-arm-eabi@npm:4.20.0"
@ -882,6 +898,75 @@ __metadata:
languageName: node
linkType: hard
"@types/d3-array@npm:^3.0.3":
version: 3.2.1
resolution: "@types/d3-array@npm:3.2.1"
checksum: 10c0/38bf2c778451f4b79ec81a2288cb4312fe3d6449ecdf562970cc339b60f280f31c93a024c7ff512607795e79d3beb0cbda123bb07010167bce32927f71364bca
languageName: node
linkType: hard
"@types/d3-color@npm:*":
version: 3.1.3
resolution: "@types/d3-color@npm:3.1.3"
checksum: 10c0/65eb0487de606eb5ad81735a9a5b3142d30bc5ea801ed9b14b77cb14c9b909f718c059f13af341264ee189acf171508053342142bdf99338667cea26a2d8d6ae
languageName: node
linkType: hard
"@types/d3-ease@npm:^3.0.0":
version: 3.0.2
resolution: "@types/d3-ease@npm:3.0.2"
checksum: 10c0/aff5a1e572a937ee9bff6465225d7ba27d5e0c976bd9eacdac2e6f10700a7cb0c9ea2597aff6b43a6ed850a3210030870238894a77ec73e309b4a9d0333f099c
languageName: node
linkType: hard
"@types/d3-interpolate@npm:^3.0.1":
version: 3.0.4
resolution: "@types/d3-interpolate@npm:3.0.4"
dependencies:
"@types/d3-color": "npm:*"
checksum: 10c0/066ebb8da570b518dd332df6b12ae3b1eaa0a7f4f0c702e3c57f812cf529cc3500ec2aac8dc094f31897790346c6b1ebd8cd7a077176727f4860c2b181a65ca4
languageName: node
linkType: hard
"@types/d3-path@npm:*":
version: 3.1.1
resolution: "@types/d3-path@npm:3.1.1"
checksum: 10c0/2c36eb31ebaf2ce4712e793fd88087117976f7c4ed69cc2431825f999c8c77cca5cea286f3326432b770739ac6ccd5d04d851eb65e7a4dbcc10c982b49ad2c02
languageName: node
linkType: hard
"@types/d3-scale@npm:^4.0.2":
version: 4.0.9
resolution: "@types/d3-scale@npm:4.0.9"
dependencies:
"@types/d3-time": "npm:*"
checksum: 10c0/4ac44233c05cd50b65b33ecb35d99fdf07566bcdbc55bc1306b2f27d1c5134d8c560d356f2c8e76b096e9125ffb8d26d95f78d56e210d1c542cb255bdf31d6c8
languageName: node
linkType: hard
"@types/d3-shape@npm:^3.1.0":
version: 3.1.7
resolution: "@types/d3-shape@npm:3.1.7"
dependencies:
"@types/d3-path": "npm:*"
checksum: 10c0/38e59771c1c4c83b67aa1f941ce350410522a149d2175832fdc06396b2bb3b2c1a2dd549e0f8230f9f24296ee5641a515eaf10f55ee1ef6c4f83749e2dd7dcfd
languageName: node
linkType: hard
"@types/d3-time@npm:*, @types/d3-time@npm:^3.0.0":
version: 3.0.4
resolution: "@types/d3-time@npm:3.0.4"
checksum: 10c0/6d9e2255d63f7a313a543113920c612e957d70da4fb0890931da6c2459010291b8b1f95e149a538500c1c99e7e6c89ffcce5554dd29a31ff134a38ea94b6d174
languageName: node
linkType: hard
"@types/d3-timer@npm:^3.0.0":
version: 3.0.2
resolution: "@types/d3-timer@npm:3.0.2"
checksum: 10c0/c644dd9571fcc62b1aa12c03bcad40571553020feeb5811f1d8a937ac1e65b8a04b759b4873aef610e28b8714ac71c9885a4d6c127a048d95118f7e5b506d9e1
languageName: node
linkType: hard
"@types/estree@npm:1.0.5":
version: 1.0.5
resolution: "@types/estree@npm:1.0.5"
@ -1389,6 +1474,13 @@ __metadata:
languageName: node
linkType: hard
"clsx@npm:^2.0.0":
version: 2.1.1
resolution: "clsx@npm:2.1.1"
checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839
languageName: node
linkType: hard
"color-convert@npm:^1.9.0":
version: 1.9.3
resolution: "color-convert@npm:1.9.3"
@ -1476,6 +1568,99 @@ __metadata:
languageName: node
linkType: hard
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6":
version: 3.2.4
resolution: "d3-array@npm:3.2.4"
dependencies:
internmap: "npm:1 - 2"
checksum: 10c0/08b95e91130f98c1375db0e0af718f4371ccacef7d5d257727fe74f79a24383e79aba280b9ffae655483ffbbad4fd1dec4ade0119d88c4749f388641c8bf8c50
languageName: node
linkType: hard
"d3-color@npm:1 - 3":
version: 3.1.0
resolution: "d3-color@npm:3.1.0"
checksum: 10c0/a4e20e1115fa696fce041fbe13fbc80dc4c19150fa72027a7c128ade980bc0eeeba4bcf28c9e21f0bce0e0dbfe7ca5869ef67746541dcfda053e4802ad19783c
languageName: node
linkType: hard
"d3-ease@npm:^3.0.1":
version: 3.0.1
resolution: "d3-ease@npm:3.0.1"
checksum: 10c0/fec8ef826c0cc35cda3092c6841e07672868b1839fcaf556e19266a3a37e6bc7977d8298c0fcb9885e7799bfdcef7db1baaba9cd4dcf4bc5e952cf78574a88b0
languageName: node
linkType: hard
"d3-format@npm:1 - 3":
version: 3.1.0
resolution: "d3-format@npm:3.1.0"
checksum: 10c0/049f5c0871ebce9859fc5e2f07f336b3c5bfff52a2540e0bac7e703fce567cd9346f4ad1079dd18d6f1e0eaa0599941c1810898926f10ac21a31fd0a34b4aa75
languageName: node
linkType: hard
"d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1":
version: 3.0.1
resolution: "d3-interpolate@npm:3.0.1"
dependencies:
d3-color: "npm:1 - 3"
checksum: 10c0/19f4b4daa8d733906671afff7767c19488f51a43d251f8b7f484d5d3cfc36c663f0a66c38fe91eee30f40327443d799be17169f55a293a3ba949e84e57a33e6a
languageName: node
linkType: hard
"d3-path@npm:^3.1.0":
version: 3.1.0
resolution: "d3-path@npm:3.1.0"
checksum: 10c0/dc1d58ec87fa8319bd240cf7689995111a124b141428354e9637aa83059eb12e681f77187e0ada5dedfce346f7e3d1f903467ceb41b379bfd01cd8e31721f5da
languageName: node
linkType: hard
"d3-scale@npm:^4.0.2":
version: 4.0.2
resolution: "d3-scale@npm:4.0.2"
dependencies:
d3-array: "npm:2.10.0 - 3"
d3-format: "npm:1 - 3"
d3-interpolate: "npm:1.2.0 - 3"
d3-time: "npm:2.1.1 - 3"
d3-time-format: "npm:2 - 4"
checksum: 10c0/65d9ad8c2641aec30ed5673a7410feb187a224d6ca8d1a520d68a7d6eac9d04caedbff4713d1e8545be33eb7fec5739983a7ab1d22d4e5ad35368c6729d362f1
languageName: node
linkType: hard
"d3-shape@npm:^3.1.0":
version: 3.2.0
resolution: "d3-shape@npm:3.2.0"
dependencies:
d3-path: "npm:^3.1.0"
checksum: 10c0/f1c9d1f09926daaf6f6193ae3b4c4b5521e81da7d8902d24b38694517c7f527ce3c9a77a9d3a5722ad1e3ff355860b014557b450023d66a944eabf8cfde37132
languageName: node
linkType: hard
"d3-time-format@npm:2 - 4":
version: 4.1.0
resolution: "d3-time-format@npm:4.1.0"
dependencies:
d3-time: "npm:1 - 3"
checksum: 10c0/735e00fb25a7fd5d418fac350018713ae394eefddb0d745fab12bbff0517f9cdb5f807c7bbe87bb6eeb06249662f8ea84fec075f7d0cd68609735b2ceb29d206
languageName: node
linkType: hard
"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:^3.0.0":
version: 3.1.0
resolution: "d3-time@npm:3.1.0"
dependencies:
d3-array: "npm:2 - 3"
checksum: 10c0/a984f77e1aaeaa182679b46fbf57eceb6ebdb5f67d7578d6f68ef933f8eeb63737c0949991618a8d29472dbf43736c7d7f17c452b2770f8c1271191cba724ca1
languageName: node
linkType: hard
"d3-timer@npm:^3.0.1":
version: 3.0.1
resolution: "d3-timer@npm:3.0.1"
checksum: 10c0/d4c63cb4bb5461d7038aac561b097cd1c5673969b27cbdd0e87fa48d9300a538b9e6f39b4a7f0e3592ef4f963d858c8a9f0e92754db73116770856f2fc04561a
languageName: node
linkType: hard
"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4":
version: 4.3.6
resolution: "debug@npm:4.3.6"
@ -1488,6 +1673,13 @@ __metadata:
languageName: node
linkType: hard
"decimal.js-light@npm:^2.4.1":
version: 2.5.1
resolution: "decimal.js-light@npm:2.5.1"
checksum: 10c0/4fd33f535aac9e5bd832796831b65d9ec7914ad129c7437b3ab991b0c2eaaa5a57e654e6174c4a17f1b3895ea366f0c1ab4955cdcdf7cfdcf3ad5a58b456c020
languageName: node
linkType: hard
"deep-is@npm:^0.1.3":
version: 0.1.4
resolution: "deep-is@npm:0.1.4"
@ -1518,6 +1710,16 @@ __metadata:
languageName: node
linkType: hard
"dom-helpers@npm:^5.0.1":
version: 5.2.1
resolution: "dom-helpers@npm:5.2.1"
dependencies:
"@babel/runtime": "npm:^7.8.7"
csstype: "npm:^3.0.2"
checksum: 10c0/f735074d66dd759b36b158fa26e9d00c9388ee0e8c9b16af941c38f014a37fc80782de83afefd621681b19ac0501034b4f1c4a3bff5caa1b8667f0212b5e124c
languageName: node
linkType: hard
"eastasianwidth@npm:^0.2.0":
version: 0.2.0
resolution: "eastasianwidth@npm:0.2.0"
@ -1799,6 +2001,13 @@ __metadata:
languageName: node
linkType: hard
"eventemitter3@npm:^4.0.1":
version: 4.0.7
resolution: "eventemitter3@npm:4.0.7"
checksum: 10c0/5f6d97cbcbac47be798e6355e3a7639a84ee1f7d9b199a07017f1d2f1e2fe236004d14fa5dfaeba661f94ea57805385e326236a6debbc7145c8877fbc0297c6b
languageName: node
linkType: hard
"eventemitter3@npm:^5.0.1":
version: 5.0.1
resolution: "eventemitter3@npm:5.0.1"
@ -1820,6 +2029,13 @@ __metadata:
languageName: node
linkType: hard
"fast-equals@npm:^5.0.1":
version: 5.2.2
resolution: "fast-equals@npm:5.2.2"
checksum: 10c0/2bfeac6317a8959a00e2134749323557e5df6dea3af24e4457297733eace8ce4313fcbca2cf4532f3a6792607461e80442cd8d3af148d5c2e4e98ad996d6e5b5
languageName: node
linkType: hard
"fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.0":
version: 3.3.2
resolution: "fast-glob@npm:3.3.2"
@ -2142,6 +2358,13 @@ __metadata:
languageName: node
linkType: hard
"internmap@npm:1 - 2":
version: 2.0.3
resolution: "internmap@npm:2.0.3"
checksum: 10c0/8cedd57f07bbc22501516fbfc70447f0c6812871d471096fad9ea603516eacc2137b633633daf432c029712df0baefd793686388ddf5737e3ea15074b877f7ed
languageName: node
linkType: hard
"ip-address@npm:^9.0.5":
version: 9.0.5
resolution: "ip-address@npm:9.0.5"
@ -2228,6 +2451,13 @@ __metadata:
languageName: node
linkType: hard
"iso-3166-1@npm:^2.1.1":
version: 2.1.1
resolution: "iso-3166-1@npm:2.1.1"
checksum: 10c0/65daf0283d22b2848d733a50ba6116a01df3e4d77970bb31d0df0696b4b386bdad0946dea53c9bb1e748e5e5e74608c07b6f4fe044908a5cffbf46f315385400
languageName: node
linkType: hard
"isomorphic-ws@npm:^5.0.0":
version: 5.0.0
resolution: "isomorphic-ws@npm:5.0.0"
@ -2377,6 +2607,7 @@ __metadata:
resolution: "lnvps_web@workspace:."
dependencies:
"@eslint/js": "npm:^9.8.0"
"@revolut/checkout": "npm:^1.1.20"
"@scure/base": "npm:^1.2.1"
"@snort/shared": "npm:^1.0.17"
"@snort/system": "npm:^1.6.1"
@ -2394,6 +2625,7 @@ __metadata:
eslint-plugin-react-hooks: "npm:^5.1.0-rc.0"
eslint-plugin-react-refresh: "npm:^0.4.9"
globals: "npm:^15.9.0"
iso-3166-1: "npm:^2.1.1"
marked: "npm:^15.0.7"
postcss: "npm:^8.4.41"
prettier: "npm:^3.3.3"
@ -2401,6 +2633,7 @@ __metadata:
react: "npm:^18.3.1"
react-dom: "npm:^18.3.1"
react-router-dom: "npm:^7.0.1"
recharts: "npm:^2.15.1"
tailwindcss: "npm:^3.4.8"
typescript: "npm:^5.5.3"
typescript-eslint: "npm:^8.0.0"
@ -2424,7 +2657,14 @@ __metadata:
languageName: node
linkType: hard
"loose-envify@npm:^1.1.0":
"lodash@npm:^4.17.21":
version: 4.17.21
resolution: "lodash@npm:4.17.21"
checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c
languageName: node
linkType: hard
"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0":
version: 1.4.0
resolution: "loose-envify@npm:1.4.0"
dependencies:
@ -2708,7 +2948,7 @@ __metadata:
languageName: node
linkType: hard
"object-assign@npm:^4.0.1":
"object-assign@npm:^4.0.1, object-assign@npm:^4.1.1":
version: 4.1.1
resolution: "object-assign@npm:4.1.1"
checksum: 10c0/1f4df9945120325d041ccf7b86f31e8bcc14e73d29171e37a7903050e96b81323784ec59f93f102ec635bcf6fa8034ba3ea0a8c7e69fa202b87ae3b6cec5a414
@ -2959,6 +3199,17 @@ __metadata:
languageName: node
linkType: hard
"prop-types@npm:^15.6.2, prop-types@npm:^15.8.1":
version: 15.8.1
resolution: "prop-types@npm:15.8.1"
dependencies:
loose-envify: "npm:^1.4.0"
object-assign: "npm:^4.1.1"
react-is: "npm:^16.13.1"
checksum: 10c0/59ece7ca2fb9838031d73a48d4becb9a7cc1ed10e610517c7d8f19a1e02fa47f7c27d557d8a5702bec3cfeccddc853579832b43f449e54635803f277b1c78077
languageName: node
linkType: hard
"punycode@npm:^2.1.0":
version: 2.3.1
resolution: "punycode@npm:2.3.1"
@ -3001,6 +3252,20 @@ __metadata:
languageName: node
linkType: hard
"react-is@npm:^16.13.1":
version: 16.13.1
resolution: "react-is@npm:16.13.1"
checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1
languageName: node
linkType: hard
"react-is@npm:^18.3.1":
version: 18.3.1
resolution: "react-is@npm:18.3.1"
checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072
languageName: node
linkType: hard
"react-refresh@npm:^0.14.2":
version: 0.14.2
resolution: "react-refresh@npm:0.14.2"
@ -3038,6 +3303,35 @@ __metadata:
languageName: node
linkType: hard
"react-smooth@npm:^4.0.4":
version: 4.0.4
resolution: "react-smooth@npm:4.0.4"
dependencies:
fast-equals: "npm:^5.0.1"
prop-types: "npm:^15.8.1"
react-transition-group: "npm:^4.4.5"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/d94cb27f808721ec040d320ca1927919199495fd212e54eb9dc8ee3f73ff1d808a34be9f4b09fe49b01f411ac2387fdf0e4bee297f18faf56f94bfbef5fd204c
languageName: node
linkType: hard
"react-transition-group@npm:^4.4.5":
version: 4.4.5
resolution: "react-transition-group@npm:4.4.5"
dependencies:
"@babel/runtime": "npm:^7.5.5"
dom-helpers: "npm:^5.0.1"
loose-envify: "npm:^1.4.0"
prop-types: "npm:^15.6.2"
peerDependencies:
react: ">=16.6.0"
react-dom: ">=16.6.0"
checksum: 10c0/2ba754ba748faefa15f87c96dfa700d5525054a0141de8c75763aae6734af0740e77e11261a1e8f4ffc08fd9ab78510122e05c21c2d79066c38bb6861a886c82
languageName: node
linkType: hard
"react@npm:^18.2.0, react@npm:^18.3.1":
version: 18.3.1
resolution: "react@npm:18.3.1"
@ -3065,6 +3359,41 @@ __metadata:
languageName: node
linkType: hard
"recharts-scale@npm:^0.4.4":
version: 0.4.5
resolution: "recharts-scale@npm:0.4.5"
dependencies:
decimal.js-light: "npm:^2.4.1"
checksum: 10c0/64ce1fc4ebe62001787bf4dc4cbb779452d33831619309c71c50277c58e8968ffe98941562d9d0d5ffdb02588ebd62f4fe6548fa826110fd458db9c3cc6dadc1
languageName: node
linkType: hard
"recharts@npm:^2.15.1":
version: 2.15.1
resolution: "recharts@npm:2.15.1"
dependencies:
clsx: "npm:^2.0.0"
eventemitter3: "npm:^4.0.1"
lodash: "npm:^4.17.21"
react-is: "npm:^18.3.1"
react-smooth: "npm:^4.0.4"
recharts-scale: "npm:^0.4.4"
tiny-invariant: "npm:^1.3.1"
victory-vendor: "npm:^36.6.8"
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/1ec3cea442382fe986500b5cc95ef3d5ee194a1cb244fa0fe288b5474c0aa501f89ed39590e1a5fec6ecdfd7ab92bb1e986699a66688423608b75b2559a865b3
languageName: node
linkType: hard
"regenerator-runtime@npm:^0.14.0":
version: 0.14.1
resolution: "regenerator-runtime@npm:0.14.1"
checksum: 10c0/1b16eb2c4bceb1665c89de70dcb64126a22bc8eb958feef3cd68fe11ac6d2a4899b5cd1b80b0774c7c03591dc57d16631a7f69d2daa2ec98100e2f29f7ec4cc4
languageName: node
linkType: hard
"resolve-from@npm:^4.0.0":
version: 4.0.0
resolution: "resolve-from@npm:4.0.0"
@ -3468,6 +3797,13 @@ __metadata:
languageName: node
linkType: hard
"tiny-invariant@npm:^1.3.1":
version: 1.3.3
resolution: "tiny-invariant@npm:1.3.3"
checksum: 10c0/65af4a07324b591a059b35269cd696aba21bef2107f29b9f5894d83cc143159a204b299553435b03874ebb5b94d019afa8b8eff241c8a4cfee95872c2e1c1c4a
languageName: node
linkType: hard
"to-fast-properties@npm:^2.0.0":
version: 2.0.0
resolution: "to-fast-properties@npm:2.0.0"
@ -3607,6 +3943,28 @@ __metadata:
languageName: node
linkType: hard
"victory-vendor@npm:^36.6.8":
version: 36.9.2
resolution: "victory-vendor@npm:36.9.2"
dependencies:
"@types/d3-array": "npm:^3.0.3"
"@types/d3-ease": "npm:^3.0.0"
"@types/d3-interpolate": "npm:^3.0.1"
"@types/d3-scale": "npm:^4.0.2"
"@types/d3-shape": "npm:^3.1.0"
"@types/d3-time": "npm:^3.0.0"
"@types/d3-timer": "npm:^3.0.0"
d3-array: "npm:^3.1.6"
d3-ease: "npm:^3.0.1"
d3-interpolate: "npm:^3.0.1"
d3-scale: "npm:^4.0.2"
d3-shape: "npm:^3.1.0"
d3-time: "npm:^3.0.0"
d3-timer: "npm:^3.0.1"
checksum: 10c0/bad36de3bf4d406834743c2e99a8281d786af324d7e84b7f7a2fc02c27a3779034fb0c3c4707d4c8e68683334d924a67100cfa13985235565e83b9877f8e2ffd
languageName: node
linkType: hard
"vite@npm:^5.4.0":
version: 5.4.0
resolution: "vite@npm:5.4.0"