@ -1 +1,2 @@
|
||||
#VITE_API_URL="http://localhost:8000"
|
||||
VITE_API_URL="http://localhost:8000"
|
||||
VITE_REVOLUT_MODE="sandbox"
|
@ -20,6 +20,7 @@
|
||||
"@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",
|
||||
|
@ -24,6 +24,7 @@ export interface AccountDetail {
|
||||
email?: string;
|
||||
contact_nip17: boolean;
|
||||
contact_email: boolean;
|
||||
country_code: string;
|
||||
}
|
||||
|
||||
export interface VmCostPlan {
|
||||
@ -141,6 +142,7 @@ export interface VmPayment {
|
||||
created: string;
|
||||
expires: string;
|
||||
amount: number;
|
||||
tax: number;
|
||||
is_paid: boolean;
|
||||
data: {
|
||||
lightning?: string;
|
||||
|
@ -1,64 +1,65 @@
|
||||
import RevolutCheckout, { Mode } from "@revolut/checkout";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { VmCostPlan } from "../api";
|
||||
|
||||
interface RevolutProps {
|
||||
amount: {
|
||||
currency: string;
|
||||
amount: number;
|
||||
};
|
||||
pubkey: string;
|
||||
loadOrder: () => Promise<string>;
|
||||
onPaid: () => void;
|
||||
onCancel?: () => void;
|
||||
mode?: string;
|
||||
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,
|
||||
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 ?? "sandbox") as Mode,
|
||||
publicToken: pubkey,
|
||||
});
|
||||
ref.innerHTML = "";
|
||||
revolutPay.mount(ref, {
|
||||
sessionToken: "",
|
||||
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);
|
||||
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?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [pubkey, ref]);
|
||||
|
||||
return <div ref={ref}></div>;
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
load(pubkey, ref.current);
|
||||
}
|
||||
}, [pubkey, ref]);
|
||||
|
||||
return <div ref={ref}></div>;
|
||||
}
|
||||
|
@ -48,7 +48,12 @@ export default function VpsPayment({
|
||||
avatar="/logo.jpg"
|
||||
className="cursor-pointer rounded-xl overflow-hidden"
|
||||
/>
|
||||
{(payment.amount / 1000).toLocaleString()} sats
|
||||
<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>
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -34,8 +34,8 @@ export default function HomePage() {
|
||||
{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 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">
|
||||
|
@ -67,6 +67,7 @@ export function VmBillingPage() {
|
||||
</div>
|
||||
{state && (
|
||||
<RevolutPayWidget
|
||||
mode={import.meta.env.VITE_REVOLUT_MODE}
|
||||
pubkey={pkey}
|
||||
amount={state.template.cost_plan}
|
||||
onPaid={() => {
|
||||
@ -122,7 +123,10 @@ export function VmBillingPage() {
|
||||
</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>
|
||||
<CostLabel cost={state.template.cost_plan} />
|
||||
<span className="text-sm">ex. tax</span>
|
||||
</div>
|
||||
</div>
|
||||
{days > 0 && (
|
||||
<div>
|
||||
|
@ -2451,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"
|
||||
@ -2618,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"
|
||||
|
Reference in New Issue
Block a user