feat: custom pricing
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-03-06 21:43:31 +00:00
parent 1aab7c9372
commit 8e3e4c0364
7 changed files with 228 additions and 24 deletions

View File

@ -9,6 +9,17 @@ 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;
@ -19,7 +30,7 @@ export interface VmCostPlan {
id: number;
name: string;
amount: number;
currency: "EUR" | "BTC";
currency: string;
interval_amount: number;
interval_type: string;
}
@ -29,16 +40,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;
}
@ -175,9 +224,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;
}
@ -222,6 +271,30 @@ export class LNVpsApi {
return data;
}
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) {
const { data } = await this.#handleResponse<ApiResponse<VmPayment>>(
await this.#req(`/api/v1/vm/${vm_id}/renew`, "GET"),

View File

@ -14,7 +14,8 @@ export default function CostLabel({ cost }: { cost: VmCostPlan }) {
return (
<>
{cost.amount} {cost.currency}/{intervalName(cost.interval_type)}
{cost.amount.toFixed(2)} {cost.currency}/
{intervalName(cost.interval_type)}
</>
);
}

View File

@ -21,11 +21,7 @@ export default function VpsPayButton({ spec }: { spec: VmTemplate }) {
return (
<AsyncButton
className={`${classNames} bg-red-900`}
onClick={() =>
navigte("/login", {
state: spec,
})
}
onClick={() => navigte("/login")}
>
Login To Order
</AsyncButton>

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

@ -1,11 +1,12 @@
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";
export default function HomePage() {
const [offers, setOffers] = useState<Array<VmTemplate>>();
const [offers, setOffers] = useState<VmTemplateResponse>();
useEffect(() => {
const api = new LNVpsApi(ApiUrl, undefined);
@ -21,14 +22,16 @@ export default function HomePage() {
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 && (
{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>
{offers?.custom_template && (
<VpsCustomOrder templates={offers.custom_template} />
)}
<small className="text-neutral-400">
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic
</small>

View File

@ -31,12 +31,21 @@ 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/billing/renew", {
state: newVm,

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,