feat: sign-in nip7
This commit is contained in:
57
src/App.tsx
57
src/App.tsx
@ -1,9 +1,12 @@
|
||||
import { SnortContext } from "@snort/system-react"
|
||||
import { CostInterval, DiskType, MachineSpec } from "./api"
|
||||
import VpsCard from "./components/vps-card"
|
||||
import { GiB, NostrProfile } from "./const"
|
||||
import { NostrSystem } from "@snort/system"
|
||||
import Profile from "./components/profile"
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { CostInterval, DiskType, MachineSpec } from "./api";
|
||||
import VpsCard from "./components/vps-card";
|
||||
import { GiB, NostrProfile } from "./const";
|
||||
import { NostrSystem } from "@snort/system";
|
||||
import Profile from "./components/profile";
|
||||
import { AsyncButton } from "./components/button";
|
||||
import { loginNip7 } from "./login";
|
||||
import LoginButton from "./components/login-button";
|
||||
|
||||
const Offers: Array<MachineSpec> = [
|
||||
{
|
||||
@ -13,13 +16,13 @@ const Offers: Array<MachineSpec> = [
|
||||
ram: 2 * GiB,
|
||||
disk: {
|
||||
type: DiskType.SSD,
|
||||
size: 80 * GiB
|
||||
size: 80 * GiB,
|
||||
},
|
||||
cost: {
|
||||
interval: CostInterval.Month,
|
||||
count: 3,
|
||||
currency: "EUR",
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "4x4x160",
|
||||
@ -28,13 +31,13 @@ const Offers: Array<MachineSpec> = [
|
||||
ram: 4 * GiB,
|
||||
disk: {
|
||||
type: DiskType.SSD,
|
||||
size: 160 * GiB
|
||||
size: 160 * GiB,
|
||||
},
|
||||
cost: {
|
||||
interval: CostInterval.Month,
|
||||
count: 5,
|
||||
currency: "EUR",
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "8x8x400",
|
||||
@ -43,50 +46,58 @@ const Offers: Array<MachineSpec> = [
|
||||
ram: 8 * GiB,
|
||||
disk: {
|
||||
type: DiskType.SSD,
|
||||
size: 400 * GiB
|
||||
size: 400 * GiB,
|
||||
},
|
||||
cost: {
|
||||
interval: CostInterval.Month,
|
||||
count: 12,
|
||||
currency: "EUR",
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const system = new NostrSystem({});
|
||||
[
|
||||
"wss://relay.snort.social/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.nostr.band/",
|
||||
"wss://nos.lol/"
|
||||
].forEach(a => system.ConnectToRelay(a, { read: true, write: true }));
|
||||
"wss://nos.lol/",
|
||||
].forEach((a) => system.ConnectToRelay(a, { read: true, write: true }));
|
||||
|
||||
export default function App() {
|
||||
|
||||
return (
|
||||
<SnortContext.Provider value={system}>
|
||||
<div className="w-[700px] mx-auto m-2 p-2">
|
||||
<h1>LNVPS</h1>
|
||||
<div className="flex items-center justify-between">
|
||||
LNVPS
|
||||
<LoginButton />
|
||||
</div>
|
||||
|
||||
<h1>VPS</h1>
|
||||
<h1>VPS Offers</h1>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Offers.map(a => <VpsCard spec={a} />)}
|
||||
{Offers.map((a) => (
|
||||
<VpsCard spec={a} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<b>Please email <a href="mailto:sales@lnvps.net">sales</a> after paying the invoice with your order id, desired OS and ssh key.</b>
|
||||
<b>
|
||||
Please email <a href="mailto:sales@lnvps.net">sales</a> after
|
||||
paying the invoice with your order id, desired OS and ssh key.
|
||||
</b>
|
||||
<b>You can also find us on nostr: </b>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Profile link={NostrProfile} />
|
||||
<pre className="overflow-x-scroll">{NostrProfile.encode()}</pre>
|
||||
</div>
|
||||
<small>
|
||||
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered traffic.
|
||||
All VPS come with 1x IPv4 and 1x IPv6 address and unmetered
|
||||
traffic.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SnortContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
21
src/components/button.tsx
Normal file
21
src/components/button.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { forwardRef, HTMLProps } from "react";
|
||||
|
||||
export type AsyncButtonProps = {
|
||||
onClick?: (e: React.MouseEvent) => Promise<void>;
|
||||
} & Omit<HTMLProps<HTMLButtonElement>, "type" | "ref" | "onClick">;
|
||||
|
||||
const AsyncButton = forwardRef<HTMLButtonElement, AsyncButtonProps>(
|
||||
function AsyncButton(props, ref) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className="bg-slate-700 py-1 px-2 rounded-xl"
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export { AsyncButton };
|
@ -1,19 +1,19 @@
|
||||
import { GiB, KiB, MiB, TiB } from "../const"
|
||||
import { GiB, KiB, MiB, TiB } from "../const";
|
||||
|
||||
interface BytesSizeProps {
|
||||
value: number,
|
||||
precision?: number
|
||||
value: number;
|
||||
precision?: number;
|
||||
}
|
||||
export default function BytesSize(props: BytesSizeProps) {
|
||||
if (props.value >= TiB) {
|
||||
return (props.value / TiB).toFixed(props.precision ?? 0) + "TB";
|
||||
} else if (props.value >= GiB) {
|
||||
return (props.value / GiB).toFixed(props.precision ?? 0) + "GB";
|
||||
} else if (props.value >= MiB) {
|
||||
return (props.value / MiB).toFixed(props.precision ?? 0) + "MB";
|
||||
} else if (props.value >= KiB) {
|
||||
return (props.value / KiB).toFixed(props.precision ?? 0) + "KB";
|
||||
} else {
|
||||
return (props.value).toFixed(props.precision ?? 0) + "B";
|
||||
}
|
||||
}
|
||||
if (props.value >= TiB) {
|
||||
return (props.value / TiB).toFixed(props.precision ?? 0) + "TB";
|
||||
} else if (props.value >= GiB) {
|
||||
return (props.value / GiB).toFixed(props.precision ?? 0) + "GB";
|
||||
} else if (props.value >= MiB) {
|
||||
return (props.value / MiB).toFixed(props.precision ?? 0) + "MB";
|
||||
} else if (props.value >= KiB) {
|
||||
return (props.value / KiB).toFixed(props.precision ?? 0) + "KB";
|
||||
} else {
|
||||
return props.value.toFixed(props.precision ?? 0) + "B";
|
||||
}
|
||||
}
|
||||
|
@ -1,14 +1,22 @@
|
||||
import { CostInterval, MachineSpec } from "../api";
|
||||
|
||||
export default function CostLabel({ cost }: { cost: MachineSpec["cost"] }) {
|
||||
function intervalName(n: number) {
|
||||
switch (n) {
|
||||
case CostInterval.Hour: return "Hour"
|
||||
case CostInterval.Day: return "Day"
|
||||
case CostInterval.Month: return "Month"
|
||||
case CostInterval.Year: return "Year"
|
||||
}
|
||||
function intervalName(n: number) {
|
||||
switch (n) {
|
||||
case CostInterval.Hour:
|
||||
return "Hour";
|
||||
case CostInterval.Day:
|
||||
return "Day";
|
||||
case CostInterval.Month:
|
||||
return "Month";
|
||||
case CostInterval.Year:
|
||||
return "Year";
|
||||
}
|
||||
}
|
||||
|
||||
return <>{cost.count} {cost.currency}/{intervalName(cost.interval)}</>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{cost.count} {cost.currency}/{intervalName(cost.interval)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
24
src/components/login-button.tsx
Normal file
24
src/components/login-button.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { SnortContext } from "@snort/system-react";
|
||||
import { useContext } from "react";
|
||||
import { AsyncButton } from "./button";
|
||||
import { loginNip7 } from "../login";
|
||||
import useLogin from "../hooks/login";
|
||||
import Profile from "./profile";
|
||||
import { NostrLink } from "@snort/system";
|
||||
|
||||
export default function LoginButton() {
|
||||
const system = useContext(SnortContext);
|
||||
const login = useLogin();
|
||||
|
||||
return !login ? (
|
||||
<AsyncButton
|
||||
onClick={async () => {
|
||||
await loginNip7(system);
|
||||
}}
|
||||
>
|
||||
Sign In
|
||||
</AsyncButton>
|
||||
) : (
|
||||
<Profile link={NostrLink.publicKey(login.pubkey)} />
|
||||
);
|
||||
}
|
@ -1,44 +1,64 @@
|
||||
import { MachineSpec } from "../api";
|
||||
|
||||
import "./pay-button.css"
|
||||
import "./pay-button.css";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
btcpay?: {
|
||||
appendInvoiceFrame(invoiceId: string): void;
|
||||
}
|
||||
}
|
||||
interface Window {
|
||||
btcpay?: {
|
||||
appendInvoiceFrame(invoiceId: string): void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function VpsPayButton({ spec }: { spec: MachineSpec }) {
|
||||
const serverUrl = "https://btcpay.v0l.io/api/v1/invoices";
|
||||
const serverUrl = "https://btcpay.v0l.io/api/v1/invoices";
|
||||
|
||||
function handleFormSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const form = event.target as HTMLFormElement;
|
||||
const xhttp = new XMLHttpRequest();
|
||||
xhttp.onreadystatechange = function () {
|
||||
if (this.readyState == 4 && this.status == 200 && this.responseText) {
|
||||
window.btcpay?.appendInvoiceFrame(JSON.parse(this.responseText).invoiceId);
|
||||
}
|
||||
};
|
||||
xhttp.open('POST', serverUrl, true);
|
||||
xhttp.send(new FormData(form));
|
||||
}
|
||||
function handleFormSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const form = event.target as HTMLFormElement;
|
||||
const xhttp = new XMLHttpRequest();
|
||||
xhttp.onreadystatechange = function () {
|
||||
if (this.readyState == 4 && this.status == 200 && this.responseText) {
|
||||
window.btcpay?.appendInvoiceFrame(
|
||||
JSON.parse(this.responseText).invoiceId,
|
||||
);
|
||||
}
|
||||
};
|
||||
xhttp.open("POST", serverUrl, true);
|
||||
xhttp.send(new FormData(form));
|
||||
}
|
||||
|
||||
if (!spec.active) {
|
||||
return <div className="text-center text-xl uppercase bg-red-800 rounded-xl py-3 font-bold">
|
||||
Unavailable
|
||||
</div>
|
||||
}
|
||||
if (!spec.active) {
|
||||
return (
|
||||
<div className="text-center text-xl uppercase bg-red-800 rounded-xl py-3 font-bold">
|
||||
Unavailable
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <form method="POST" action={serverUrl} className="btcpay-form btcpay-form--block" onSubmit={handleFormSubmit}>
|
||||
<input type="hidden" name="storeId" value="CdaHy1puLx4kLC9BG3A9mu88XNyLJukMJRuuhAfbDrxg" />
|
||||
<input type="hidden" name="jsonResponse" value="true" />
|
||||
<input type="hidden" name="orderId" value={spec.id} />
|
||||
<input type="hidden" name="price" value={spec.cost.count} />
|
||||
<input type="hidden" name="currency" value={spec.cost.currency} />
|
||||
<input type="image" className="submit" name="submit" src="https://btcpay.v0l.io/img/paybutton/pay.svg"
|
||||
alt="Pay with BTCPay Server, a Self-Hosted Bitcoin Payment Processor" />
|
||||
return (
|
||||
<form
|
||||
method="POST"
|
||||
action={serverUrl}
|
||||
className="btcpay-form btcpay-form--block w-full"
|
||||
onSubmit={handleFormSubmit}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="storeId"
|
||||
value="CdaHy1puLx4kLC9BG3A9mu88XNyLJukMJRuuhAfbDrxg"
|
||||
/>
|
||||
<input type="hidden" name="jsonResponse" value="true" />
|
||||
<input type="hidden" name="orderId" value={spec.id} />
|
||||
<input type="hidden" name="price" value={spec.cost.count} />
|
||||
<input type="hidden" name="currency" value={spec.cost.currency} />
|
||||
<input
|
||||
type="image"
|
||||
className="w-full"
|
||||
name="submit"
|
||||
src="https://btcpay.v0l.io/img/paybutton/pay.svg"
|
||||
alt="Pay with BTCPay Server, a Self-Hosted Bitcoin Payment Processor"
|
||||
/>
|
||||
</form>
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -3,11 +3,18 @@ import { NostrLink } from "@snort/system";
|
||||
import { useUserProfile } from "@snort/system-react";
|
||||
|
||||
export default function Profile({ link }: { link: NostrLink }) {
|
||||
const profile = useUserProfile(link.id);
|
||||
return <div className="flex gap-2 items-center">
|
||||
<img src={profile?.picture} className="w-12 h-12 rounded-full bg-neutral-500" />
|
||||
<div>
|
||||
{profile?.display_name ?? profile?.name ?? hexToBech32("npub", link.id).slice(0, 12)}
|
||||
</div>
|
||||
const profile = useUserProfile(link.id);
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<img
|
||||
src={profile?.picture}
|
||||
className="w-12 h-12 rounded-full bg-neutral-500"
|
||||
/>
|
||||
<div>
|
||||
{profile?.display_name ??
|
||||
profile?.name ??
|
||||
hexToBech32("npub", link.id).slice(0, 12)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -4,14 +4,23 @@ import CostLabel from "./cost";
|
||||
import VpsPayButton from "./pay-button";
|
||||
|
||||
export default function VpsCard({ spec }: { spec: MachineSpec }) {
|
||||
return <div className="rounded-xl border border-neutral-600 px-3 py-2">
|
||||
<h2>{spec.id}</h2>
|
||||
<ul>
|
||||
<li>CPU: {spec.cpu}vCPU</li>
|
||||
<li>RAM: <BytesSize value={spec.ram} /></li>
|
||||
<li>{spec.disk.type === DiskType.SSD ? "SSD" : "HDD"}: <BytesSize value={spec.disk.size} /></li>
|
||||
</ul>
|
||||
<h2><CostLabel cost={spec.cost} /></h2>
|
||||
<VpsPayButton spec={spec} />
|
||||
return (
|
||||
<div className="rounded-xl border border-neutral-600 px-3 py-2">
|
||||
<h2>{spec.id}</h2>
|
||||
<ul>
|
||||
<li>CPU: {spec.cpu}vCPU</li>
|
||||
<li>
|
||||
RAM: <BytesSize value={spec.ram} />
|
||||
</li>
|
||||
<li>
|
||||
{spec.disk.type === DiskType.SSD ? "SSD" : "HDD"}:{" "}
|
||||
<BytesSize value={spec.disk.size} />
|
||||
</li>
|
||||
</ul>
|
||||
<h2>
|
||||
<CostLabel cost={spec.cost} />
|
||||
</h2>
|
||||
<VpsPayButton spec={spec} />
|
||||
</div>
|
||||
}
|
||||
);
|
||||
}
|
||||
|
16
src/const.ts
16
src/const.ts
@ -12,9 +12,15 @@ export const GB = KB * 1000;
|
||||
export const TB = GB * 1000;
|
||||
export const PB = TB * 1000;
|
||||
|
||||
export const NostrProfile = new NostrLink(NostrPrefix.Profile,
|
||||
"fcd818454002a6c47a980393f0549ac6e629d28d5688114bb60d831b5c1832a7",
|
||||
undefined, undefined, [
|
||||
"wss://nos.lol/", "wss://relay.nostr.bg/", "wss://relay.damus.io", "wss://relay.snort.social/"
|
||||
]
|
||||
export const NostrProfile = new NostrLink(
|
||||
NostrPrefix.Profile,
|
||||
"fcd818454002a6c47a980393f0549ac6e629d28d5688114bb60d831b5c1832a7",
|
||||
undefined,
|
||||
undefined,
|
||||
[
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.nostr.bg/",
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.snort.social/",
|
||||
],
|
||||
);
|
||||
|
12
src/hooks/login.tsx
Normal file
12
src/hooks/login.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { Login } from "../login";
|
||||
|
||||
export default function useLogin() {
|
||||
return useSyncExternalStore(
|
||||
(c) => {
|
||||
Login?.on("change", c);
|
||||
return () => Login?.off("change", c);
|
||||
},
|
||||
() => Login,
|
||||
);
|
||||
}
|
@ -35,4 +35,4 @@ h3 {
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
14
src/login.ts
Normal file
14
src/login.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Nip7Signer, SystemInterface, UserState } from "@snort/system";
|
||||
|
||||
export let Login: UserState<void> | undefined;
|
||||
|
||||
export async function loginNip7(system: SystemInterface) {
|
||||
const signer = new Nip7Signer();
|
||||
const pubkey = await signer.getPubKey();
|
||||
if (pubkey) {
|
||||
Login = new UserState<void>(pubkey);
|
||||
await Login.init(signer, system);
|
||||
} else {
|
||||
throw new Error("No nostr extension found");
|
||||
}
|
||||
}
|
12
src/main.tsx
12
src/main.tsx
@ -1,10 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
|
Reference in New Issue
Block a user