Massive user-story-oriented refactor

This commit is contained in:
Jon Staab
2026-06-01 10:24:21 -07:00
parent 0018a5d4f3
commit f5403b6aef
28 changed files with 971 additions and 428 deletions
+6 -47
View File
@@ -1,12 +1,11 @@
import { A, useLocation } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import Fuse from "fuse.js"
import { primeProfiles, useProfilePicture, useTenant, useTenantRelays, type Relay } from "@/lib/hooks"
import { invoiceStatus, listTenantInvoices, type Invoice } from "@/lib/api"
import { primeProfiles, useProfilePicture, useTenantRelays, type Relay } from "@/lib/hooks"
import { account, eventStore, identity } from "@/lib/state"
import serverIcon from "@/assets/server.svg"
import Modal from "@/components/Modal"
import PaymentDialog from "@/components/PaymentDialog"
import BillingPrompts from "@/components/BillingPrompts"
type Profile = {
name?: string
@@ -35,28 +34,10 @@ function RelayIcon() {
export default function AppShell(props: { children?: any }) {
const location = useLocation()
const picture = useProfilePicture(() => account()?.pubkey)
const [tenant] = useTenant()
const [tenantRelays] = useTenantRelays()
const [profile, setProfile] = createSignal<Profile>({})
const [searchOpen, setSearchOpen] = createSignal(false)
const [searchQuery, setSearchQuery] = createSignal("")
const [pastDueInvoice, setPastDueInvoice] = createSignal<Invoice | undefined>()
const [showPaymentDialog, setShowPaymentDialog] = createSignal(false)
createEffect(async () => {
const t = tenant()
if (!t?.churned_at) {
setPastDueInvoice(undefined)
return
}
try {
const invoices = await listTenantInvoices(t.pubkey)
const openInvoice = invoices.find(inv => invoiceStatus(inv) === "open")
setPastDueInvoice(openInvoice)
} catch {
// ignore
}
})
const username = createMemo(() => profile().name || profile().display_name || shortenPubkey(account()?.pubkey))
const nip05 = createMemo(() => profile().nip05 || "No NIP-05")
@@ -158,36 +139,14 @@ export default function AppShell(props: { children?: any }) {
</aside>
<div class="md:pl-[260px] min-h-screen pb-20 md:pb-0">
<Show when={tenant()?.churned_at}>
<div class="bg-red-600 text-white px-4 py-3 text-sm flex items-center justify-between">
<span>Your account is past due and some relays are paused. Update your payment method to restore service.</span>
<Show when={pastDueInvoice()}>
<button
type="button"
onClick={() => setShowPaymentDialog(true)}
class="font-medium underline hover:no-underline"
>
Pay now
</button>
</Show>
</div>
{/* Shared billing prompts on every dashboard page; the billing page
renders its own contextual (inline) variant instead. */}
<Show when={location.pathname !== "/account"}>
<BillingPrompts variant="banner" />
</Show>
<main>{props.children}</main>
</div>
<Show when={pastDueInvoice() && showPaymentDialog()}>
{(_) => {
const invoice = pastDueInvoice()!
return (
<PaymentDialog
invoice={invoice}
open={true}
onClose={() => setShowPaymentDialog(false)}
/>
)
}}
</Show>
<nav
class="fixed inset-x-0 bottom-0 z-20 border-t border-gray-200 bg-white md:hidden"
onClick={() => {
+128
View File
@@ -0,0 +1,128 @@
import { createMemo, createResource, createSignal, Show } from "solid-js"
import { useSearchParams } from "@solidjs/router"
import PromptBanner, { type PromptBannerAction } from "@/components/PromptBanner"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, type Invoice } from "@/lib/api"
import { activeBillingPrompt, billingFlowActive, useBillingStatus, type BillingPromptKind } from "@/lib/billing"
type BillingPromptsProps = {
// "banner" sits in the dashboard shell (mounted on every page except the
// billing page); "inline" is shown contextually on the billing page itself.
// Only one is ever mounted at a time, so each can own its own modals + deep link.
variant?: "banner" | "inline"
}
export default function BillingPrompts(props: BillingPromptsProps) {
const status = useBillingStatus()
const [dismissed, setDismissed] = createSignal<Set<BillingPromptKind>>(new Set())
const [payInvoice, setPayInvoice] = createSignal<Invoice | undefined>()
const [setupOpen, setSetupOpen] = createSignal(false)
const [setupTab, setSetupTab] = createSignal<"nwc" | "card">("nwc")
// Deep link: /...?invoice=<id> (e.g. from the billing DM) opens the payment
// dialog on whatever dashboard page the link lands on.
const [searchParams, setSearchParams] = useSearchParams()
const [deepLinked] = createResource(
() => searchParams.invoice as string | undefined,
(id) => getInvoice(id),
)
const prompt = createMemo(() =>
activeBillingPrompt(
{
tenant: status.tenant(),
openInvoice: status.openInvoice(),
hasPaidSubscription: status.hasPaidSubscription(),
},
{ suppressInline: billingFlowActive() },
),
)
const visiblePrompt = createMemo(() => {
const p = prompt()
if (!p || dismissed().has(p.kind)) return undefined
return p
})
function openSetup(tab: "nwc" | "card") {
setSetupTab(tab)
setSetupOpen(true)
}
const actions = createMemo<PromptBannerAction[]>(() => {
const p = visiblePrompt()
if (!p) return []
const open = status.openInvoice()
switch (p.kind) {
case "churned":
return open
? [
{ label: "Pay now", onClick: () => setPayInvoice(open) },
{ label: "Update payment method", onClick: () => openSetup("nwc") },
]
: [{ label: "Update payment method", onClick: () => openSetup("nwc") }]
case "pay_invoice":
return open ? [{ label: "Pay now", onClick: () => setPayInvoice(open) }] : []
case "update_method":
return [{ label: "Update payment method", onClick: () => openSetup(status.tenant()?.nwc_error ? "nwc" : "card") }]
case "setup_autopay":
return [{ label: "Set up autopay", onClick: () => openSetup("nwc") }]
}
return []
})
const dismissible = () => visiblePrompt()?.kind === "setup_autopay"
function dismiss() {
const p = visiblePrompt()
if (p) setDismissed((prev) => new Set(prev).add(p.kind))
}
function clearDeepLink() {
if (searchParams.invoice) setSearchParams({ invoice: undefined })
}
const outerClass = () => (props.variant === "inline" ? "" : "mx-4 mt-4 md:mx-6")
return (
<>
<Show when={visiblePrompt()}>
{(p) => (
<PromptBanner
severity={p().severity}
message={p().message}
actions={actions()}
onDismiss={dismissible() ? dismiss : undefined}
class={outerClass()}
/>
)}
</Show>
{/* Pay an invoice — from a prompt action or a deep link. */}
<Show when={payInvoice() ?? deepLinked()}>
{(invoice) => (
<PaymentDialog
invoice={invoice()}
open={true}
onClose={() => {
const wasDeepLink = !payInvoice()
setPayInvoice(undefined)
if (wasDeepLink) clearDeepLink()
status.refetch()
}}
/>
)}
</Show>
<PaymentSetup
open={setupOpen()}
initialTab={setupTab()}
onClose={() => {
setSetupOpen(false)
status.refetch()
}}
/>
</>
)
}
+30 -31
View File
@@ -1,10 +1,9 @@
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { createEffect, createResource, createSignal, For, Show } from "solid-js"
import QRCode from "qrcode"
import Modal from "@/components/Modal"
import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, getInvoiceBolt11, type Invoice } from "@/lib/api"
import { useTenantRelays } from "@/lib/hooks"
import { plans } from "@/lib/state"
import { getInvoice, getInvoiceBolt11, listInvoiceItems, type Invoice } from "@/lib/api"
import { billingTenant } from "@/lib/state"
type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
@@ -27,14 +26,15 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const [payError, setPayError] = createSignal("")
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [setupSaved, setSetupSaved] = createSignal(false)
const [relays] = useTenantRelays()
const [items] = createResource(
() => (props.open ? props.invoice.id : undefined),
listInvoiceItems,
)
const billedRelays = createMemo(() => {
const planById = new Map(plans().map((p) => [p.id, p]))
return (relays() ?? [])
.map((relay) => ({ relay, plan: planById.get(relay.plan_id) }))
.filter((entry) => Boolean(entry.plan?.amount))
})
const autopayConfigured = () => {
const t = billingTenant()
return Boolean(t?.nwc_is_set || t?.stripe_payment_method_id)
}
async function loadBolt11() {
if (!props.invoice.id) return
@@ -44,9 +44,9 @@ export default function PaymentDialog(props: PaymentDialogProps) {
setQrDataUrl("")
try {
const { bolt11: invoice } = await getInvoiceBolt11(props.invoice.id)
setBolt11(invoice)
setQrDataUrl(await QRCode.toDataURL(invoice, { width: 256, margin: 2 }))
const { lnbc } = await getInvoiceBolt11(props.invoice.id)
setBolt11(lnbc)
setQrDataUrl(await QRCode.toDataURL(lnbc, { width: 256, margin: 2 }))
setBolt11Status("ready")
} catch (e) {
setBolt11Status("error")
@@ -137,19 +137,16 @@ export default function PaymentDialog(props: PaymentDialogProps) {
when={payStatus() === "success"}
fallback={
<div class="w-full space-y-4">
{/* What's being paid for */}
<Show when={billedRelays().length > 0}>
{/* What's being paid for — the invoice's actual line items */}
<Show when={(items() ?? []).length > 0}>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Relays on this invoice</p>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">On this invoice</p>
<ul class="space-y-1.5">
<For each={billedRelays()}>
{({ relay, plan }) => (
<For each={items()}>
{(item) => (
<li class="flex items-center justify-between gap-3 text-sm">
<span class="truncate text-gray-900">{relay.info_name || relay.subdomain}</span>
<span class="flex-shrink-0 text-xs text-gray-500">
{plan?.name ?? relay.plan_id}
<Show when={plan}> · ${(plan!.amount / 100).toFixed(2)}/mo</Show>
</span>
<span class="truncate text-gray-900">{item.description}</span>
<span class="flex-shrink-0 text-xs text-gray-500">${(item.amount / 100).toFixed(2)}</span>
</li>
)}
</For>
@@ -221,13 +218,15 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</div>
<p class="text-sm font-medium text-gray-900">Payment confirmed!</p>
<p class="text-xs text-gray-500">Thank you. Your account is up to date.</p>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Set up automatic payments
</button>
<Show when={!autopayConfigured()}>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Set up automatic payments
</button>
</Show>
</div>
</Show>
</div>
+10 -2
View File
@@ -1,4 +1,4 @@
import { createSignal, Show } from "solid-js"
import { createEffect, createSignal, Show } from "solid-js"
import Modal from "@/components/Modal"
import { updateActiveTenant } from "@/lib/hooks"
import { createPortalSession } from "@/lib/api"
@@ -10,10 +10,18 @@ type PaymentSetupProps = {
open: boolean
onClose: () => void
onSaved?: () => void
// Which method to show first. Lightning/NWC is the default; pass "card" to land
// a tenant on the card tab (e.g. when their card is the method that failed).
initialTab?: Tab
}
export default function PaymentSetup(props: PaymentSetupProps) {
const [tab, setTab] = createSignal<Tab>("nwc")
const [tab, setTab] = createSignal<Tab>(props.initialTab ?? "nwc")
// Reset to the requested tab each time the dialog opens.
createEffect(() => {
if (props.open) setTab(props.initialTab ?? "nwc")
})
const [nwcUrl, setNwcUrl] = createSignal("")
const [saving, setSaving] = createSignal(false)
const [saved, setSaved] = createSignal(false)
+61
View File
@@ -0,0 +1,61 @@
import { For, Show } from "solid-js"
export type PromptBannerAction = {
label: string
onClick: () => void
}
type Severity = "error" | "warn" | "info"
type PromptBannerProps = {
severity: Severity
message: string
actions?: PromptBannerAction[]
onDismiss?: () => void
class?: string
}
const containerStyles: Record<Severity, string> = {
error: "border-red-200 bg-red-50 text-red-800",
warn: "border-amber-200 bg-amber-50 text-amber-800",
info: "border-blue-200 bg-blue-50 text-blue-800",
}
const actionStyles: Record<Severity, string> = {
error: "text-red-800 hover:text-red-900",
warn: "text-amber-800 hover:text-amber-900",
info: "text-blue-800 hover:text-blue-900",
}
export default function PromptBanner(props: PromptBannerProps) {
return (
<div class={`rounded-lg border p-4 flex items-start justify-between gap-4 ${containerStyles[props.severity]} ${props.class ?? ""}`.trim()}>
<p class="text-sm min-w-0">{props.message}</p>
<div class="flex items-center gap-3 shrink-0">
<For each={props.actions ?? []}>
{(action) => (
<button
type="button"
onClick={action.onClick}
class={`text-sm font-medium underline hover:no-underline whitespace-nowrap ${actionStyles[props.severity]}`}
>
{action.label}
</button>
)}
</For>
<Show when={props.onDismiss}>
<button
type="button"
onClick={() => props.onDismiss?.()}
aria-label="Dismiss"
class={`shrink-0 opacity-70 hover:opacity-100 ${actionStyles[props.severity]}`}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</Show>
</div>
</div>
)
}
+29 -1
View File
@@ -97,6 +97,7 @@ export type Tenant = {
pubkey: string
nwc_is_set: boolean
created_at: number
billing_anchor: number | null
stripe_customer_id: string
stripe_payment_method_id: string | null
nwc_error: string | null
@@ -113,6 +114,29 @@ export type Invoice = {
created_at: number
paid_at: number | null
voided_at: number | null
method: "nwc" | "stripe" | "oob" | null
}
export type InvoiceItem = {
id: string
invoice_id: string | null
activity_id: string | null
tenant_pubkey: string
relay_id: string
plan_id: string
amount: number
description: string
created_at: number
}
export type Bolt11 = {
id: string
invoice_id: string
lnbc: string
msats: number
created_at: number
expires_at: number
settled_at: number | null
}
// The backend models an invoice's lifecycle as timestamps rather than a status
@@ -265,7 +289,11 @@ export function createPortalSession(pubkey: string, returnUrl?: string) {
}
export function getInvoiceBolt11(invoiceId: string) {
return callApi<undefined, { bolt11: string }>("GET", `/invoices/${invoiceId}/bolt11`)
return callApi<undefined, Bolt11>("GET", `/invoices/${invoiceId}/bolt11`)
}
export function listInvoiceItems(invoiceId: string) {
return callApi<undefined, InvoiceItem[]>("GET", `/invoices/${invoiceId}/items`)
}
export function createRelay(input: CreateRelayInput) {
+106
View File
@@ -0,0 +1,106 @@
import { createMemo, createSignal } from "solid-js"
import { invoiceStatus, type Invoice, type Tenant } from "@/lib/api"
import { billingInvoices, billingRelays, billingTenant, plans, refetchBilling } from "@/lib/state"
// Set while the create/upgrade flow drives its own payment/setup modals, so the
// shared prompt surface doesn't double-prompt for the same invoice. Cleared on
// every close path of that flow.
export const [billingFlowActive, setBillingFlowActive] = createSignal(false)
export type BillingPromptKind = "churned" | "pay_invoice" | "update_method" | "setup_autopay"
export type BillingPrompt = {
kind: BillingPromptKind
severity: "error" | "warn" | "info"
message: string
}
export type BillingStatusSnapshot = {
tenant: Tenant | undefined
openInvoice: Invoice | undefined
hasPaidSubscription: boolean
}
// The single billing read shared by the dashboard shell and the billing page.
// `openInvoice` is the OLDEST open, positive invoice — matching the backend's
// dunning order so the UI pays the same one collection targets.
export function useBillingStatus() {
const tenant = () => billingTenant()
const invoices = () => billingInvoices() ?? []
const openInvoices = createMemo(() =>
invoices()
.filter((inv) => invoiceStatus(inv) === "open" && inv.amount > 0)
.sort((a, b) => a.created_at - b.created_at),
)
const openInvoice = () => openInvoices()[0]
// Amount due: the total of all open invoices.
const balance = () => openInvoices().reduce((sum, inv) => sum + inv.amount, 0)
const hasPaidSubscription = createMemo(() => {
const planById = new Map(plans().map((p) => [p.id, p]))
return (billingRelays() ?? []).some((relay) => {
const plan = planById.get(relay.plan_id)
return Boolean(plan && plan.amount > 0 && relay.status === "active")
})
})
const loading = () => billingTenant.loading || billingInvoices.loading
return { tenant, invoices, balance, openInvoice, hasPaidSubscription, loading, refetch: refetchBilling }
}
// Pure priority selector: returns the single highest-priority billing prompt to
// surface, or null. Priority: churned > pay an open invoice > fix a failed method
// > set up autopay. `suppressInline` hides the prompts the create/upgrade inline
// flow already handles (pay_invoice, setup_autopay) while still surfacing churn
// and method errors.
export function activeBillingPrompt(
s: BillingStatusSnapshot,
opts?: { suppressInline?: boolean },
): BillingPrompt | null {
const tenant = s.tenant
if (!tenant) return null
if (tenant.churned_at) {
return {
kind: "churned",
severity: "error",
message:
"Your account is past due and some relays are paused. Pay your balance or update your payment method to restore service.",
}
}
const autopayConfigured = tenant.nwc_is_set || Boolean(tenant.stripe_payment_method_id)
const methodError = tenant.nwc_error ?? tenant.stripe_error
const suppressInline = opts?.suppressInline ?? false
if (s.openInvoice && !suppressInline && (!autopayConfigured || methodError)) {
return {
kind: "pay_invoice",
severity: "warn",
message: "You have an unpaid invoice. Pay it now to keep your relays running.",
}
}
if (methodError) {
return {
kind: "update_method",
severity: "warn",
message: tenant.nwc_error
? "Your Lightning wallet couldn't be charged. Update your payment method."
: "Your card couldn't be charged. Update your payment method.",
}
}
if (s.hasPaidSubscription && !autopayConfigured && !s.openInvoice && !suppressInline) {
return {
kind: "setup_autopay",
severity: "info",
message: "Set up automatic payments so your subscription renews without interruption.",
}
}
return null
}
+17 -1
View File
@@ -6,7 +6,7 @@ import { EventStore } from "applesauce-core"
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
import { RelayPool } from "applesauce-relay"
import { NostrConnectSigner } from "applesauce-signers"
import { getIdentity, listPlans, registerAccountGetter, type Plan } from "@/lib/api"
import { getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, registerAccountGetter, type Plan } from "@/lib/api"
export type UnsignedEvent = {
kind: number
@@ -55,6 +55,22 @@ export const [identity, { refetch: refetchIdentity, mutate: setIdentity }] = cre
}
)
// Shared billing reads, fetched once per session and consumed by the dashboard
// shell, the billing page, and the billing-prompt surface. Keyed on the active
// pubkey so they refetch on account switch; refetchBilling() refreshes them all
// after a mutation (payment, method update, plan change).
const billingKey = () => account()?.pubkey
export const [billingTenant, { refetch: refetchBillingTenant }] = createResource(billingKey, getTenant)
export const [billingInvoices, { refetch: refetchBillingInvoices }] = createResource(billingKey, listTenantInvoices)
export const [billingRelays, { refetch: refetchBillingRelays }] = createResource(billingKey, listTenantRelays)
export function refetchBilling() {
void refetchBillingTenant()
void refetchBillingInvoices()
void refetchBillingRelays()
}
// Deferred to avoid circular-import TDZ errors (api.ts <-> state.ts)
queueMicrotask(() => {
try {
+139
View File
@@ -0,0 +1,139 @@
import { createSignal } from "solid-js"
import QRCode from "qrcode"
import { getInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api"
import { PLATFORM_NAME } from "@/lib/state"
const methodLabels: Record<string, string> = {
nwc: "Lightning",
stripe: "Card",
oob: "Lightning (out of band)",
}
const fmtUsd = (cents: number) => `$${(cents / 100).toFixed(2)}`
const fmtDate = (seconds: number) => new Date(seconds * 1000).toLocaleDateString()
function escapeHtml(value: string) {
const map: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }
return value.replace(/[&<>"']/g, (c) => map[c])
}
// Generates a printable invoice and opens the browser's print/save-as-PDF dialog.
// No PDF dependency: the invoice is rendered as standalone HTML into an off-screen
// iframe so the current page is never disturbed. The bitcoin line is included only
// for Lightning-relevant invoices (never card-paid or void) to avoid spuriously
// minting a bolt11.
export function useInvoicePdf() {
const [printing, setPrinting] = createSignal(false)
async function printInvoice(invoice: Invoice) {
if (printing()) return
setPrinting(true)
try {
const items = await listInvoiceItems(invoice.id).catch(() => [] as InvoiceItem[])
let sats: number | undefined
let qrDataUrl: string | undefined
if (invoice.method !== "stripe" && invoice.voided_at == null) {
try {
const bolt11 = await getInvoiceBolt11(invoice.id)
sats = Math.round(bolt11.msats / 1000)
if (invoice.paid_at == null) {
qrDataUrl = await QRCode.toDataURL(bolt11.lnbc, { width: 180, margin: 1 })
}
} catch {
// no bolt11 available — omit the bitcoin line
}
}
printHtml(buildHtml({ invoice, items, sats, qrDataUrl }))
} finally {
setPrinting(false)
}
}
return { printInvoice, printing }
}
function buildHtml(opts: { invoice: Invoice; items: InvoiceItem[]; sats?: number; qrDataUrl?: string }): string {
const { invoice, items, sats, qrDataUrl } = opts
const status = invoiceStatus(invoice)
const rows = items.length
? items
.map((i) => `<tr><td>${escapeHtml(i.description || "Charge")}</td><td class="amt">${fmtUsd(i.amount)}</td></tr>`)
.join("")
: `<tr><td>Relay subscription</td><td class="amt">${fmtUsd(invoice.amount)}</td></tr>`
const satsRow = sats != null ? `<tr><td>Bitcoin equivalent</td><td class="amt">${sats.toLocaleString()} sats</td></tr>` : ""
const methodLine = invoice.method ? `<div>Paid via ${escapeHtml(methodLabels[invoice.method] ?? invoice.method)}</div>` : ""
const qr = qrDataUrl
? `<div class="qr"><img src="${qrDataUrl}" alt="Lightning invoice QR"/><div class="muted">Scan to pay by Lightning</div></div>`
: ""
return `<!doctype html><html><head><meta charset="utf-8"><title>Invoice ${escapeHtml(invoice.id)}</title>
<style>
* { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; color: #111827; box-sizing: border-box; }
body { margin: 40px; }
h1 { font-size: 20px; margin: 0 0 4px; }
.muted { color: #6b7280; font-size: 12px; }
.head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
.badge { text-transform: capitalize; border: 1px solid #d1d5db; border-radius: 9999px; padding: 2px 10px; font-size: 12px; }
.meta { font-size: 13px; color: #374151; line-height: 1.6; word-break: break-all; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { text-align: left; padding: 8px 0; border-bottom: 1px solid #e5e7eb; font-size: 14px; }
.amt { text-align: right; white-space: nowrap; }
tfoot td { font-weight: 600; border-bottom: none; border-top: 2px solid #111827; }
.qr { margin-top: 28px; text-align: center; }
</style></head>
<body>
<div class="head">
<div><h1>${escapeHtml(PLATFORM_NAME)}</h1><div class="muted">Invoice</div></div>
<span class="badge">${status}</span>
</div>
<div class="meta">
<div>Invoice ID: ${escapeHtml(invoice.id)}</div>
<div>Billed to: ${escapeHtml(invoice.tenant_pubkey)}</div>
<div>Issued: ${fmtDate(invoice.created_at)}</div>
<div>Period: ${fmtDate(invoice.period_start)} &ndash; ${fmtDate(invoice.period_end)}</div>
${methodLine}
</div>
<table>
<thead><tr><th>Description</th><th class="amt">Amount</th></tr></thead>
<tbody>${rows}${satsRow}</tbody>
<tfoot><tr><td>Total</td><td class="amt">${fmtUsd(invoice.amount)}</td></tr></tfoot>
</table>
${qr}
</body></html>`
}
function printHtml(html: string) {
const iframe = document.createElement("iframe")
iframe.style.position = "fixed"
iframe.style.right = "0"
iframe.style.bottom = "0"
iframe.style.width = "0"
iframe.style.height = "0"
iframe.style.border = "0"
document.body.appendChild(iframe)
const win = iframe.contentWindow
const doc = win?.document
if (!win || !doc) {
iframe.remove()
return
}
doc.open()
doc.write(html)
doc.close()
const cleanup = () => window.setTimeout(() => iframe.remove(), 1000)
win.onafterprint = cleanup
// Let the iframe lay out (and decode the QR image) before printing.
window.setTimeout(() => {
win.focus()
win.print()
window.setTimeout(cleanup, 60000)
}, 150)
}
+6 -1
View File
@@ -104,8 +104,13 @@ export default function useRelayToggles(
if (plan_id !== "free") {
const needsSetup = await tenantNeedsPaymentSetup()
if (needsSetup) {
// Materialize the invoice for this upgrade (no collection, no DM) so we
// can prompt the tenant to pay it directly. listTenantInvoices reconciles
// first, so a just-created invoice is visible here.
const invoice = await getLatestOpenInvoice()
if (invoice) setPendingInvoice(invoice)
if (invoice) {
setPendingInvoice(invoice)
}
setPendingPaymentSetup(true)
}
}
+72 -51
View File
@@ -1,48 +1,61 @@
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"
import { useSearchParams } from "@solidjs/router"
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 BillingPrompts from "@/components/BillingPrompts"
import useMinLoading from "@/components/useMinLoading"
import { updateActiveTenant, useTenant } from "@/lib/hooks"
import { createPortalSession, getInvoice, invoiceStatus, listTenantInvoices, type Invoice } from "@/lib/api"
import { useInvoicePdf } from "@/lib/useInvoicePdf"
import { updateActiveTenant } from "@/lib/hooks"
import { useBillingStatus } from "@/lib/billing"
import { createPortalSession, invoiceStatus, type Invoice } from "@/lib/api"
import { account } from "@/lib/state"
const methodLabels: Record<string, string> = {
nwc: "Lightning",
stripe: "Card",
oob: "Lightning",
}
const invoiceStatusStyles: Record<string, string> = {
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
paid: "bg-green-50 text-green-700 border-green-200",
void: "bg-gray-100 text-gray-500 border-gray-200",
}
export default function Account() {
const [tenant, { refetch: refetchTenant }] = useTenant()
const [invoices, { refetch: refetchInvoices }] = createResource(() => listTenantInvoices(account()!.pubkey))
const billing = useBillingStatus()
const [nwcUrl, setNwcUrl] = createSignal("")
const [saving, setSaving] = createSignal(false)
const [error, setError] = createSignal("")
const [selectedInvoice, setSelectedInvoice] = createSignal<Invoice | undefined>()
const [portalLoading, setPortalLoading] = createSignal(false)
const invoicesLoading = useMinLoading(() => invoices.loading)
const invoicesLoading = useMinLoading(() => billing.loading())
const { printInvoice, printing } = useInvoicePdf()
// Deep link: /account?invoice=<id> (e.g. from the billing DM) fetches that
// invoice and opens the payment dialog. The fetched invoice takes precedence
// over a row the user clicked in the list.
const [searchParams, setSearchParams] = useSearchParams()
const [deepLinkedInvoice] = createResource(
() => searchParams.invoice as string | undefined,
(id) => getInvoice(id),
)
// On landing here (the billing portal returns to this page), refresh billing so
// a card just added in the portal — which get_tenant syncs onto the tenant — and
// any cleared error show immediately rather than only on the next reconcile.
createEffect(() => {
if (deepLinkedInvoice.error) setError("Couldn't load that invoice. It may no longer be available.")
if (account()?.pubkey) billing.refetch()
})
const activeInvoice = () => selectedInvoice() ?? deepLinkedInvoice()
// The backend never returns the stored nwc_url (it's private), so the input is
// write-only: we can only act on a newly entered URL, not prefill the saved one.
const hasBillingChanges = createMemo(() => nwcUrl().trim().length > 0)
// The amount to surface: the total of any open invoices, else nothing owed.
const balance = createMemo(() => {
const due = billing.balance()
return due > 0 ? { kind: "due" as const, amount: due } : { kind: "clear" as const, amount: 0 }
})
async function saveBilling() {
setError("")
setSaving(true)
try {
const next = nwcUrl().trim()
await updateActiveTenant({ nwc_url: next })
await updateActiveTenant({ nwc_url: nwcUrl().trim() })
setNwcUrl("")
await refetchTenant()
billing.refetch()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update billing")
} finally {
@@ -50,13 +63,6 @@ export default function Account() {
}
}
function handleInvoiceDialogClose() {
setSelectedInvoice(undefined)
// Clearing the query param drops the deep-linked invoice and closes the dialog.
if (searchParams.invoice) setSearchParams({ invoice: undefined })
void refetchInvoices()
}
async function openPortal() {
setPortalLoading(true)
try {
@@ -74,12 +80,6 @@ export default function Account() {
window.location.href = "/"
}
const invoiceStatusStyles: Record<string, string> = {
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
paid: "bg-green-50 text-green-700 border-green-200",
void: "bg-gray-100 text-gray-500 border-gray-200",
}
return (
<PageContainer>
<div class="mb-6 py-2 flex items-center justify-between gap-3">
@@ -94,10 +94,13 @@ export default function Account() {
</div>
<div class="space-y-6">
{/* Billing prompts, emphasized contextually on the billing page. */}
<BillingPrompts variant="inline" />
<section class="bg-white border border-gray-200 rounded-xl p-6">
<div class="flex items-center justify-between gap-3">
<h2 class="text-lg font-semibold text-gray-900">Account Status</h2>
<Show when={tenant()}>
<Show when={billing.tenant()}>
<span class="rounded-full border border-gray-300 bg-gray-100 px-2.5 py-1 text-xs font-medium uppercase tracking-wide text-gray-700">
tenant
</span>
@@ -117,10 +120,22 @@ export default function Account() {
{portalLoading() ? "Loading..." : "Manage Billing"}
</button>
</div>
{/* Current balance */}
<div class="mb-4 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Current balance</p>
<Show
when={balance().kind === "due"}
fallback={<p class="text-sm text-gray-600 mt-1">You're all paid up.</p>}
>
<p class="text-2xl font-bold text-gray-900 mt-0.5">${(balance().amount / 100).toFixed(2)} <span class="text-sm font-normal text-gray-500">due</span></p>
</Show>
</div>
<p class="text-sm text-gray-600 mb-4">
Enable automatic payments by providing your Nostr Wallet Connect URL.
Enable automatic payments by providing your Nostr Wallet Connect URL, or add a card via Manage Billing.
</p>
<Show when={tenant()?.nwc_is_set}>
<Show when={billing.tenant()?.nwc_is_set}>
<p class="text-sm text-green-700 mb-4">A wallet is connected. Enter a new URL to replace it.</p>
</Show>
<div class="flex gap-2">
@@ -140,17 +155,6 @@ export default function Account() {
{saving() ? "Saving..." : "Save"}
</button>
</div>
<Show when={tenant()?.churned_at}>
<p class="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
Your account is past due and some relays have been paused. Update your payment method below to restore service.
</p>
</Show>
<Show when={tenant()?.nwc_error}>
<p class="mt-3 text-sm text-red-600">Lightning auto-payment failed: {tenant()!.nwc_error}</p>
</Show>
<Show when={tenant()?.stripe_error}>
<p class="mt-3 text-sm text-red-600">Card auto-payment failed: {tenant()!.stripe_error}</p>
</Show>
<Show when={error()}>
<p class="mt-3 text-sm text-red-600">{error()}</p>
</Show>
@@ -162,9 +166,9 @@ export default function Account() {
<LoadingState message="Loading invoices..." paddingClass="py-8" />
</Show>
<Show when={!invoicesLoading()}>
<Show when={(invoices()?.length ?? 0) > 0} fallback={<p class="text-gray-500 text-sm">No invoices yet.</p>}>
<Show when={billing.invoices().length > 0} fallback={<p class="text-gray-500 text-sm">No invoices yet.</p>}>
<ul class="space-y-3">
<For each={invoices()}>
<For each={billing.invoices()}>
{(invoice) => {
const status = () => invoiceStatus(invoice)
const isOpen = () => status() === "open"
@@ -186,6 +190,9 @@ export default function Account() {
<span class="font-medium text-gray-900">
${(invoice.amount / 100).toFixed(2)}
</span>
<Show when={invoice.method}>
<span class="text-xs text-gray-500"> · paid via {methodLabels[invoice.method!] ?? invoice.method}</span>
</Show>
<Show when={invoice.period_start && invoice.period_end}>
<p class="text-xs text-gray-500 mt-0.5">{periodLabel()}</p>
</Show>
@@ -197,6 +204,17 @@ export default function Account() {
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${statusStyle()}`}>
{status()}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void printInvoice(invoice)
}}
disabled={printing()}
class="text-xs font-medium text-gray-500 hover:text-gray-800 underline disabled:opacity-50"
>
PDF
</button>
</div>
</div>
</li>
@@ -209,12 +227,15 @@ export default function Account() {
</section>
</div>
<Show when={activeInvoice()}>
<Show when={selectedInvoice()}>
{(invoice) => (
<PaymentDialog
invoice={invoice()}
open={true}
onClose={handleInvoiceDialogClose}
onClose={() => {
setSelectedInvoice(undefined)
billing.refetch()
}}
/>
)}
</Show>
+10 -78
View File
@@ -1,5 +1,5 @@
import { useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js"
import { createEffect, createResource, createSignal, onCleanup, Show } from "solid-js"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import PaymentDialog from "@/components/PaymentDialog"
@@ -9,9 +9,10 @@ import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed"
import { listRelayMembers } from "@/lib/api"
import { getLatestOpenInvoice, useRelay, useRelayActivity, useTenant } from "@/lib/hooks"
import { useRelay, useRelayActivity } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles"
import { plans } from "@/lib/state"
import { setBillingFlowActive } from "@/lib/billing"
import { refetchBilling } from "@/lib/state"
export default function RelayDetail() {
const params = useParams()
@@ -30,10 +31,7 @@ export default function RelayDetail() {
const [activity] = useRelayActivity(relayId)
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, pendingPaymentSetup, clearPendingPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
const [tenant, { refetch: refetchTenant }] = useTenant()
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false)
const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false)
createEffect(() => {
if (pendingPaymentSetup() && !pendingInvoice()) {
@@ -42,25 +40,10 @@ export default function RelayDetail() {
}
})
const isPaidRelay = createMemo(() => {
const r = relay()
if (!r) return false
const plan = plans().find(p => p.id === r.plan_id)
return !!(plan && plan.amount > 0)
})
const [openInvoice, { refetch: refetchOpenInvoice }] = createResource(
isPaidRelay,
async (paid) => paid ? getLatestOpenInvoice() : null
)
const showPaymentNudge = createMemo(() => {
if (paymentBannerDismissed()) return false
if (!isPaidRelay()) return false
const t = tenant()
if (!t) return false
return !t.nwc_is_set && !t.stripe_payment_method_id
})
// Suppress the shared banner's redundant pay/setup prompts while this page's
// own inline plan-change modals are open.
createEffect(() => setBillingFlowActive(Boolean(pendingInvoice()) || paymentSetupOpen()))
onCleanup(() => setBillingFlowActive(false))
return (
<PageContainer>
@@ -69,44 +52,6 @@ export default function RelayDetail() {
<Show when={!loading() && relay()}>
{(r) => (
<div class="space-y-6 mb-6">
<Show when={showPaymentNudge()}>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-amber-800">Payment setup recommended</p>
<p class="text-sm text-amber-700 mt-1">
This relay is on a paid plan. Invoices are due when your subscription starts. Set up NWC or Stripe for automatic payments, or pay open invoices via Lightning.
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<Show when={openInvoice()}>
<button
type="button"
onClick={() => setInvoiceDialogOpen(true)}
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
>
Pay invoice
</button>
</Show>
<button
type="button"
onClick={() => setPaymentSetupOpen(true)}
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
>
Set up payments
</button>
<button
type="button"
onClick={() => setPaymentBannerDismissed(true)}
aria-label="Dismiss"
class="text-amber-500 hover:text-amber-800 shrink-0"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Show>
<RelayDetailCard
relay={r()}
currentMembers={members()?.length}
@@ -129,20 +74,7 @@ export default function RelayDetail() {
open={true}
onClose={() => {
clearPendingInvoice()
void refetchTenant()
void refetchOpenInvoice()
}}
/>
)}
</Show>
<Show when={openInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()!}
open={invoiceDialogOpen()}
onClose={() => {
setInvoiceDialogOpen(false)
void refetchOpenInvoice()
void refetchBilling()
}}
/>
)}
@@ -151,7 +83,7 @@ export default function RelayDetail() {
open={paymentSetupOpen()}
onClose={() => {
setPaymentSetupOpen(false)
void refetchTenant()
void refetchBilling()
}}
/>
</PageContainer>
+13 -1
View File
@@ -1,4 +1,4 @@
import { createSignal, Show } from "solid-js"
import { createEffect, createSignal, onCleanup, Show } from "solid-js"
import { useNavigate } from "@solidjs/router"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
@@ -7,6 +7,8 @@ import PaymentSetup from "@/components/PaymentSetup"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { createRelayForActiveTenant, getLatestOpenInvoice, tenantNeedsPaymentSetup } from "@/lib/hooks"
import type { Invoice } from "@/lib/api"
import { setBillingFlowActive } from "@/lib/billing"
import { refetchBilling } from "@/lib/state"
export default function RelayNew() {
const navigate = useNavigate()
@@ -14,13 +16,22 @@ export default function RelayNew() {
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
let createdRelayId = ""
// While this flow's inline modals are open, suppress the shared banner's
// overlapping pay/setup prompts (it still surfaces churn / method errors).
createEffect(() => setBillingFlowActive(Boolean(pendingInvoice()) || paymentSetupOpen()))
onCleanup(() => setBillingFlowActive(false))
async function handleSubmit(values: RelayFormValues) {
const relay = await createRelayForActiveTenant(values)
createdRelayId = relay.id
void refetchBilling()
if (values.plan_id !== "free") {
const needsSetup = await tenantNeedsPaymentSetup()
if (needsSetup) {
// Materialize the invoice for this change (no collection, no DM) so we
// can prompt the tenant to pay it directly. listTenantInvoices reconciles
// first, so a just-created invoice is visible here.
const invoice = await getLatestOpenInvoice()
if (invoice) {
setPendingInvoice(invoice)
@@ -41,6 +52,7 @@ export default function RelayNew() {
function handleSetupClose() {
setPaymentSetupOpen(false)
void refetchBilling()
navigate(`/relays/${createdRelayId}`)
}