From 15394f55d2d814404dd12109278252ee6100e5d1 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Tue, 31 Mar 2026 08:02:35 -0700 Subject: [PATCH] Add invoice payment dialog --- frontend/src/components/PaymentDialog.tsx | 218 ++++++++++++++++++++++ frontend/src/lib/api.ts | 1 + frontend/src/lib/hooks.ts | 8 +- frontend/src/lib/useRelayToggles.ts | 10 +- frontend/src/pages/Account.tsx | 78 ++++++-- frontend/src/pages/relays/RelayDetail.tsx | 12 +- frontend/src/pages/relays/RelayNew.tsx | 28 ++- 7 files changed, 337 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/PaymentDialog.tsx diff --git a/frontend/src/components/PaymentDialog.tsx b/frontend/src/components/PaymentDialog.tsx new file mode 100644 index 0000000..b003e0c --- /dev/null +++ b/frontend/src/components/PaymentDialog.tsx @@ -0,0 +1,218 @@ +import { createEffect, createSignal, Show } from "solid-js" +import QRCode from "qrcode" +import Modal from "@/components/Modal" +import { getInvoice, type Invoice } from "@/lib/api" + +type Tab = "bitcoin" | "card" +type PayStatus = "idle" | "loading" | "success" | "error" + +type PaymentDialogProps = { + invoice: Invoice + open: boolean + onClose: () => void +} + +export default function PaymentDialog(props: PaymentDialogProps) { + const [tab, setTab] = createSignal("bitcoin") + const [qrDataUrl, setQrDataUrl] = createSignal("") + const [payStatus, setPayStatus] = createSignal("idle") + const [payError, setPayError] = createSignal("") + + createEffect(async () => { + const bolt11 = props.invoice?.bolt11 + if (!bolt11) return + setQrDataUrl(await QRCode.toDataURL(bolt11, { width: 256, margin: 2 })) + }) + + function copyBolt11() { + void navigator.clipboard.writeText(props.invoice.bolt11) + } + + async function checkPayment() { + setPayStatus("loading") + setPayError("") + try { + const invoice = await getInvoice(props.invoice.id) + if (invoice.status === "paid") { + setPayStatus("success") + } else { + setPayStatus("error") + setPayError("Payment not yet confirmed. Please try again after sending.") + } + } catch (e) { + setPayStatus("error") + setPayError(e instanceof Error ? e.message : "Failed to check payment status") + } + } + + function handleClose() { + setPayStatus("idle") + setPayError("") + props.onClose() + } + + const periodLabel = () => { + const start = new Date(props.invoice.period_start * 1000) + const end = new Date(props.invoice.period_end * 1000) + return `${start.toLocaleDateString()} – ${end.toLocaleDateString()}` + } + + return ( + + {/* Header */} +
+
+
+

Pay Invoice

+ +

+ {props.invoice.amount.toLocaleString()} sats +

+
+ +

{periodLabel()}

+
+
+ +
+
+ + {/* Pay with label + Tabs */} +
+

Pay with

+
+ + +
+
+ + {/* Tab content */} +
+ + + Generating invoice...
} + > + Lightning invoice QR code + +
+ + +
+ + } + > +
+
+ + + +
+

Payment confirmed!

+

Thank you. Your relay is now active.

+
+ + + + +
+
+ + + + +
+

Coming soon

+

Card payments are not yet available.

+
+
+ + + {/* Error */} + +
+

{payError()}

+
+
+ + {/* Footer */} +
+ + +
+ } + > + + + + +
+ ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dbc5c36..6879ebe 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -104,6 +104,7 @@ export type Invoice = { id: string tenant: string status: string + amount: number created_at: number attempted_at: number error: string diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts index 3665c03..f30f892 100644 --- a/frontend/src/lib/hooks.ts +++ b/frontend/src/lib/hooks.ts @@ -18,6 +18,7 @@ import { updateTenantBilling, type Activity, type CreateRelayInput, + type Invoice, type Relay, type Tenant, type UpdateRelayInput, @@ -129,6 +130,11 @@ export const updateRelayPlanById = (id: string, plan: string) => updateRelay(id, export const deactivateRelayById = (id: string) => deactivateRelay(id) +export async function checkPendingInvoice(): Promise { + const invoices = await listTenantInvoices(account()!.pubkey) + return invoices.find(inv => inv.status === "pending") +} + export async function getRelayMembers(url: string) { const management = new RelayManagement(new NostrRelay(url), account()!.signer) @@ -139,4 +145,4 @@ export async function getRelayMembers(url: string) { } } -export type { Activity, Relay, Tenant } +export type { Activity, Invoice, Relay, Tenant } diff --git a/frontend/src/lib/useRelayToggles.ts b/frontend/src/lib/useRelayToggles.ts index 93e6ac8..98af1cd 100644 --- a/frontend/src/lib/useRelayToggles.ts +++ b/frontend/src/lib/useRelayToggles.ts @@ -1,5 +1,5 @@ import { createSignal } from "solid-js" -import { updateRelayById, deactivateRelayById, type Relay } from "@/lib/hooks" +import { updateRelayById, deactivateRelayById, checkPendingInvoice, type Invoice, type Relay } from "@/lib/hooks" import { setToastMessage } from "@/components/Toast" import type { PlanId } from "@/lib/api" @@ -30,6 +30,7 @@ export default function useRelayToggles( { refetch, mutate }: RelayActions, ) { const [busy, setBusy] = createSignal(false) + const [pendingInvoice, setPendingInvoice] = createSignal() async function updateRelay(next: Relay, previous: Relay) { mutate(next) @@ -85,6 +86,11 @@ export default function useRelayToggles( setToastMessage(e instanceof Error ? e.message : "Failed to update relay plan") throw e } + + if (plan !== "free") { + const invoice = await checkPendingInvoice() + if (invoice) setPendingInvoice(invoice) + } } const toggles = { @@ -97,5 +103,5 @@ export default function useRelayToggles( onToggleLivekitSupport: () => toggle("livekit_enabled", relay()?.plan !== "free"), } - return { busy, handleDeactivate, handleUpdatePlan, toggles } + return { busy, handleDeactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice: () => setPendingInvoice(undefined), toggles } } diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx index 652c08a..6405cd1 100644 --- a/frontend/src/pages/Account.tsx +++ b/frontend/src/pages/Account.tsx @@ -1,15 +1,17 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js" import PageContainer from "@/components/PageContainer" import LoadingState from "@/components/LoadingState" +import PaymentDialog from "@/components/PaymentDialog" import useMinLoading from "@/components/useMinLoading" -import { updateActiveTenantBilling, useTenant, useTenantInvoices } from "@/lib/hooks" +import { updateActiveTenantBilling, useTenant, useTenantInvoices, type Invoice } from "@/lib/hooks" export default function Account() { const [tenant, { refetch: refetchTenant }] = useTenant() - const [invoices] = useTenantInvoices() + const [invoices, { refetch: refetchInvoices }] = useTenantInvoices() const [nwcUrl, setNwcUrl] = createSignal("") const [saving, setSaving] = createSignal(false) const [error, setError] = createSignal("") + const [selectedInvoice, setSelectedInvoice] = createSignal() const invoicesLoading = useMinLoading(() => invoices.loading) const hasBillingChanges = createMemo(() => { @@ -36,11 +38,22 @@ export default function Account() { } } + function handleInvoiceDialogClose() { + setSelectedInvoice(undefined) + void refetchInvoices() + } + function logout() { localStorage.clear() window.location.href = "/" } + const invoiceStatusStyles: Record = { + pending: "bg-yellow-50 text-yellow-700 border-yellow-200", + paid: "bg-green-50 text-green-700 border-green-200", + closed: "bg-gray-100 text-gray-500 border-gray-200", + } + return (
@@ -99,25 +112,64 @@ export default function Account() { - 0} fallback={

No invoices yet.

}> + 0} fallback={

No invoices yet.

}>
    - {(invoice) => ( -
  • -
    - - {invoice.status} -
    -

    {new Date(invoice.created_at * 1000).toLocaleString()}

    -

    {invoice.bolt11}

    -
  • - )} + {(invoice) => { + const isPending = () => invoice.status === "pending" + const statusStyle = () => invoiceStatusStyles[invoice.status] ?? "bg-gray-100 text-gray-500 border-gray-200" + const periodLabel = () => { + const start = new Date(invoice.period_start * 1000) + const end = new Date(invoice.period_end * 1000) + return `${start.toLocaleDateString()} – ${end.toLocaleDateString()}` + } + + return ( +
  • isPending() && setSelectedInvoice(invoice)} + title={isPending() ? "Click to pay this invoice" : undefined} + > +
    +
    + + {invoice.amount ? `${invoice.amount.toLocaleString()} sats` : "—"} + + +

    {periodLabel()}

    +
    +
    +
    + + Pay now → + + + {invoice.status} + +
    +
    + +

    {invoice.error}

    +
    +
  • + ) + }}
+ + + {(invoice) => ( + + )} +
) } diff --git a/frontend/src/pages/relays/RelayDetail.tsx b/frontend/src/pages/relays/RelayDetail.tsx index a34d487..6e6a6e7 100644 --- a/frontend/src/pages/relays/RelayDetail.tsx +++ b/frontend/src/pages/relays/RelayDetail.tsx @@ -2,6 +2,7 @@ import { useParams } from "@solidjs/router" import { createMemo, createResource, Show } from "solid-js" import BackLink from "@/components/BackLink" import PageContainer from "@/components/PageContainer" +import PaymentDialog from "@/components/PaymentDialog" import RelayDetailCard from "@/components/RelayDetailCard" import ResourceState from "@/components/ResourceState" import useMinLoading from "@/components/useMinLoading" @@ -20,7 +21,7 @@ export default function RelayDetail() { const [members] = createResource(relayUrl, getRelayMembers) const loading = useMinLoading(() => relay.loading && !relay()) const [activity] = useRelayActivity(relayId) - const { busy, handleDeactivate, handleUpdatePlan, toggles } = useRelayToggles(relayId, relay, { refetch, mutate }) + const { busy, handleDeactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, toggles } = useRelayToggles(relayId, relay, { refetch, mutate }) return ( @@ -42,6 +43,15 @@ export default function RelayDetail() { )} + + {(invoice) => ( + + )} + ) } diff --git a/frontend/src/pages/relays/RelayNew.tsx b/frontend/src/pages/relays/RelayNew.tsx index b6fe6cd..4c3c9c6 100644 --- a/frontend/src/pages/relays/RelayNew.tsx +++ b/frontend/src/pages/relays/RelayNew.tsx @@ -1,17 +1,36 @@ +import { createSignal } from "solid-js" import { useNavigate } from "@solidjs/router" import BackLink from "@/components/BackLink" import PageContainer from "@/components/PageContainer" +import PaymentDialog from "@/components/PaymentDialog" import RelayForm, { type RelayFormValues } from "@/components/RelayForm" -import { createRelayForActiveTenant } from "@/lib/hooks" +import { checkPendingInvoice, createRelayForActiveTenant, type Invoice } from "@/lib/hooks" export default function RelayNew() { const navigate = useNavigate() + const [pendingInvoice, setPendingInvoice] = createSignal() + let createdRelayId = "" async function handleSubmit(values: RelayFormValues) { const relay = await createRelayForActiveTenant(values) + createdRelayId = relay.id + + if (values.plan !== "free") { + const invoice = await checkPendingInvoice() + if (invoice) { + setPendingInvoice(invoice) + return + } + } + navigate(`/relays/${relay.id}`) } + function handleDialogClose() { + setPendingInvoice(undefined) + navigate(`/relays/${createdRelayId}`) + } + return ( @@ -22,6 +41,13 @@ export default function RelayNew() { submitLabel="Create Relay" submittingLabel="Creating..." /> + {pendingInvoice() && ( + + )} ) }