diff --git a/frontend/src/components/AdminInvoiceListItem.tsx b/frontend/src/components/AdminInvoiceListItem.tsx index d0a1497..b747300 100644 --- a/frontend/src/components/AdminInvoiceListItem.tsx +++ b/frontend/src/components/AdminInvoiceListItem.tsx @@ -15,12 +15,14 @@ const invoiceStatusStyles: Record = { type AdminInvoiceListItemProps = { invoice: Invoice href: string + showTenant?: boolean } export default function AdminInvoiceListItem(props: AdminInvoiceListItemProps) { - // Resolve the owning tenant's profile from the event store. AdminInvoiceList - // primes these profiles in one batch, so this subscription does not prime. - const metadata = useProfileMetadata(() => props.invoice.tenant_pubkey, { prime: false }) + // Resolve the owning tenant's profile from the event store. The list that + // passes `showTenant` is responsible for priming these profiles in one batch, + // so this subscription does not prime on its own. + const metadata = useProfileMetadata(() => (props.showTenant ? props.invoice.tenant_pubkey : undefined), { prime: false }) const status = () => invoiceStatus(props.invoice) @@ -31,19 +33,21 @@ export default function AdminInvoiceListItem(props: AdminInvoiceListItemProps) {

{formatUsd(props.invoice.amount)}

{formatPeriod(props.invoice.period_start, props.invoice.period_end)}

-
- - {((metadata()?.name || metadata()?.display_name) || props.invoice.tenant_pubkey).slice(0, 1).toUpperCase()} -
- } - > - - - {(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.invoice.tenant_pubkey)} -
+ +
+ + {((metadata()?.name || metadata()?.display_name) || props.invoice.tenant_pubkey).slice(0, 1).toUpperCase()} +
+ } + > + +
+ {(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.invoice.tenant_pubkey)} + + {status()} diff --git a/frontend/src/components/BackLink.tsx b/frontend/src/components/BackLink.tsx index aa78953..b5854d7 100644 --- a/frontend/src/components/BackLink.tsx +++ b/frontend/src/components/BackLink.tsx @@ -1,14 +1,27 @@ import { A } from "@solidjs/router" +import { Show } from "solid-js" type BackLinkProps = { - href: string + // Omit to pop the previous history entry instead of navigating to a fixed + // route — for pages reachable from several places, "back" should land the + // user wherever they came from. + href?: string label: string } export default function BackLink(props: BackLinkProps) { return (
- ← {props.label} + history.back()} class="text-gray-500 hover:text-gray-700"> + ← {props.label} + + } + > + {(href) => ← {props.label}} +
) } diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts index 56fae76..30e6f12 100644 --- a/frontend/src/lib/hooks.ts +++ b/frontend/src/lib/hooks.ts @@ -30,6 +30,7 @@ import { type UpdateRelayInput, } from "@/lib/api" import { autopayConfigured } from "@/lib/paymentMethod" +import { decidePostPaidFlow, type PaidFlowDecision } from "@/lib/relayPlanFlow" import { account, eventStore, pool } from "@/lib/state" import { useNostr } from "@/lib/nostr" @@ -155,6 +156,8 @@ export const useAdminTenant = (pubkey: () => string) => createResource(pubkey, g export const useAdminTenantRelays = (pubkey: () => string) => createResource(pubkey, listTenantRelays) +export const useAdminTenantInvoices = (pubkey: () => string) => createResource(pubkey, listTenantInvoices) + export const createRelayForActiveTenant = (input: CreateRelayInput) => { const defaults = { info_name: "", @@ -199,5 +202,16 @@ export async function getLatestOpenInvoice(): Promise { return open[0] ?? null } +// Resolve what to do after a paid create/upgrade succeeds: a tenant that already +// has autopay configured just navigates, otherwise we surface the freshly +// materialized open invoice to pay directly (or fall back to payment setup when +// none is available). Shared by RelayNew, Home's signup-and-create path, and the +// plan-upgrade toggle so the post-paid ladder stays identical across all three. +export async function resolvePostPaidFlow(): Promise { + const needsSetup = await tenantNeedsPaymentSetup() + const invoice = needsSetup ? await getLatestOpenInvoice() : null + return decidePostPaidFlow({ needsSetup, invoice }) +} + export type { Activity, Invoice, Relay, Tenant } export type { ProfileContent } diff --git a/frontend/src/lib/useRelayToggles.ts b/frontend/src/lib/useRelayToggles.ts index c8a0e60..fe97bfc 100644 --- a/frontend/src/lib/useRelayToggles.ts +++ b/frontend/src/lib/useRelayToggles.ts @@ -1,7 +1,7 @@ import { createSignal } from "solid-js" -import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks" +import { updateRelayById, deactivateRelayById, reactivateRelayById, resolvePostPaidFlow, type Relay } from "@/lib/hooks" import { setToastMessage } from "@/lib/state" -import { applyPlanToRelay, decidePostPaidFlow, planUpdatePayload, toggleField } from "@/lib/relayPlanFlow" +import { applyPlanToRelay, planUpdatePayload, toggleField } from "@/lib/relayPlanFlow" import type { Invoice, PlanId } from "@/lib/api" type RelayResource = { @@ -85,12 +85,10 @@ export default function useRelayToggles( if (plan_id === "free") return - // 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 needsSetup = await tenantNeedsPaymentSetup() - const invoice = needsSetup ? await getLatestOpenInvoice() : null - const decision = decidePostPaidFlow({ needsSetup, invoice }) + // Paid upgrades materialize an open invoice; resolvePostPaidFlow reconciles + // and decides whether to prompt the tenant to pay it, set up a payment method, + // or do nothing when autopay is already configured. + const decision = await resolvePostPaidFlow() switch (decision.kind) { case "pay_invoice": setPendingInvoice(decision.invoice) diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 73bb200..f041224 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -5,9 +5,12 @@ import ExternalLinkIcon from "@/components/ExternalLinkIcon" import PricingTable from "@/components/PricingTable" import RelayForm, { type RelayFormValues } from "@/components/RelayForm" import Modal from "@/components/Modal" +import PaymentDialog from "@/components/PaymentDialog" +import PaymentSetup from "@/components/PaymentSetup" import Login from "@/views/Login" -import { createRelayForActiveTenant } from "@/lib/hooks" -import { account } from "@/lib/state" +import { createRelayForActiveTenant, resolvePostPaidFlow } from "@/lib/hooks" +import type { Invoice } from "@/lib/api" +import { account, refetchBilling, setToastMessage } from "@/lib/state" import FlotillaLogo from "@/assets/flotilla-logo.svg" import NostordLogo from "@/assets/nostord-logo.svg" @@ -17,6 +20,9 @@ export default function Home() { const [showLoginModal, setShowLoginModal] = createSignal(false) const [draftRelay, setDraftRelay] = createSignal() const [initialPlanId, setInitialPlanId] = createSignal("free") + const [pendingInvoice, setPendingInvoice] = createSignal() + const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false) + let createdRelayId = "" function openRelayModal(planId: RelayFormValues["plan_id"] = "free") { setInitialPlanId(planId) @@ -24,25 +30,67 @@ export default function Home() { } async function onRelayFormSubmit(values: RelayFormValues) { - if (account()) { - const relay = await createRelayForActiveTenant(values) - - navigate(`/relays/${relay.id}`) - } else { + // Not signed in yet: stash the draft and send them through login. The relay + // (and any payment prompt) is created in onAuthenticated once the session and + // its tenant exist, so signing up and creating a paid relay in one go still + // surfaces the invoice. + if (!account()) { setDraftRelay(values) setShowRelayModal(false) setShowLoginModal(true) + return } + + const relay = await createRelayForActiveTenant(values) + createdRelayId = relay.id + setShowRelayModal(false) + + // Paid plans materialize an open invoice on create. A just-signed-up tenant + // has no payment method yet, so open the payment dialog here instead of + // dropping them on the relay page with no prompt (the shared dashboard banner + // only catches up once they navigate and its billing reads refetch). + if (values.plan_id !== "free") { + void refetchBilling() + const decision = await resolvePostPaidFlow() + if (decision.kind === "pay_invoice") { + setPendingInvoice(decision.invoice) + return + } + if (decision.kind === "setup") { + setPaymentSetupOpen(true) + return + } + } + + navigate(`/relays/${relay.id}`) } async function onAuthenticated() { + setShowLoginModal(false) const relay = draftRelay() + setDraftRelay(undefined) - if (relay) { - onRelayFormSubmit(relay) - } else { + if (!relay) { navigate("/relays") + return } + + try { + await onRelayFormSubmit(relay) + } catch (e) { + setToastMessage(e instanceof Error ? e.message : "Failed to create relay") + } + } + + function handleInvoiceClose() { + setPendingInvoice(undefined) + setPaymentSetupOpen(true) + } + + function handleSetupClose() { + setPaymentSetupOpen(false) + void refetchBilling() + navigate(`/relays/${createdRelayId}`) } return ( @@ -384,6 +432,20 @@ export default function Home() { onAuthenticated={onAuthenticated} /> + + + {(inv) => ( + + )} + + ) } diff --git a/frontend/src/pages/admin/AdminInvoiceDetail.tsx b/frontend/src/pages/admin/AdminInvoiceDetail.tsx index 40c886b..c27c284 100644 --- a/frontend/src/pages/admin/AdminInvoiceDetail.tsx +++ b/frontend/src/pages/admin/AdminInvoiceDetail.tsx @@ -26,7 +26,7 @@ export default function AdminInvoiceDetail() { return ( - + {(i) => ( diff --git a/frontend/src/pages/admin/AdminInvoiceList.tsx b/frontend/src/pages/admin/AdminInvoiceList.tsx index f0a2af7..6a0f80d 100644 --- a/frontend/src/pages/admin/AdminInvoiceList.tsx +++ b/frontend/src/pages/admin/AdminInvoiceList.tsx @@ -39,7 +39,7 @@ export default function AdminInvoiceList() { 0} fallback={

No invoices found.

}>
    - {(invoice) => } + {(invoice) => }
diff --git a/frontend/src/pages/admin/AdminRelayDetail.tsx b/frontend/src/pages/admin/AdminRelayDetail.tsx index c32837e..b5acbc6 100644 --- a/frontend/src/pages/admin/AdminRelayDetail.tsx +++ b/frontend/src/pages/admin/AdminRelayDetail.tsx @@ -29,7 +29,7 @@ export default function AdminRelayDetail() { return ( - + {(r) => ( diff --git a/frontend/src/pages/admin/AdminTenantDetail.tsx b/frontend/src/pages/admin/AdminTenantDetail.tsx index 8cdcdb2..4d2b7ce 100644 --- a/frontend/src/pages/admin/AdminTenantDetail.tsx +++ b/frontend/src/pages/admin/AdminTenantDetail.tsx @@ -4,9 +4,10 @@ import { getProfilePicture } from "applesauce-core/helpers/profile" import BackLink from "@/components/BackLink" import PageContainer from "@/components/PageContainer" import RelayListItem from "@/components/RelayListItem" +import AdminInvoiceListItem from "@/components/AdminInvoiceListItem" import ResourceState from "@/components/ResourceState" import useMinLoading from "@/lib/useMinLoading" -import { useAdminTenant, useAdminTenantRelays, useProfileMetadata } from "@/lib/hooks" +import { useAdminTenant, useAdminTenantInvoices, useAdminTenantRelays, useProfileMetadata } from "@/lib/hooks" import { shortenPubkey } from "@/lib/pubkey" export default function AdminTenantDetail() { @@ -14,7 +15,8 @@ export default function AdminTenantDetail() { const tenantId = () => params.id ?? "" const [tenant] = useAdminTenant(tenantId) const [relays] = useAdminTenantRelays(tenantId) - const loading = useMinLoading(() => tenant.loading || relays.loading) + const [invoices] = useAdminTenantInvoices(tenantId) + const loading = useMinLoading(() => tenant.loading || relays.loading || invoices.loading) const metadata = useProfileMetadata(tenantId) const churnedLabel = () => { @@ -25,7 +27,7 @@ export default function AdminTenantDetail() { return ( - +
{tenantId()}

- +
@@ -96,6 +98,16 @@ export default function AdminTenantDetail() {
+
+

Invoices

+ 0} fallback={

No invoices.

}> +
    + + {(invoice) => } + +
+
+
diff --git a/frontend/src/pages/relays/RelayDetail.tsx b/frontend/src/pages/relays/RelayDetail.tsx index b5ee478..705d620 100644 --- a/frontend/src/pages/relays/RelayDetail.tsx +++ b/frontend/src/pages/relays/RelayDetail.tsx @@ -46,7 +46,7 @@ export default function RelayDetail() { return ( - + {(r) => ( diff --git a/frontend/src/pages/relays/RelayNew.tsx b/frontend/src/pages/relays/RelayNew.tsx index 64e787a..9e7c173 100644 --- a/frontend/src/pages/relays/RelayNew.tsx +++ b/frontend/src/pages/relays/RelayNew.tsx @@ -5,9 +5,8 @@ import PageContainer from "@/components/PageContainer" import PaymentDialog from "@/components/PaymentDialog" import PaymentSetup from "@/components/PaymentSetup" import RelayForm, { type RelayFormValues } from "@/components/RelayForm" -import { createRelayForActiveTenant, getLatestOpenInvoice, tenantNeedsPaymentSetup } from "@/lib/hooks" +import { createRelayForActiveTenant, resolvePostPaidFlow } from "@/lib/hooks" import type { Invoice } from "@/lib/api" -import { decidePostPaidFlow } from "@/lib/relayPlanFlow" import { refetchBilling, setBillingFlowActive } from "@/lib/state" export default function RelayNew() { @@ -38,12 +37,10 @@ export default function RelayNew() { void refetchBilling() if (paid) { - // 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 needsSetup = await tenantNeedsPaymentSetup() - const invoice = needsSetup ? await getLatestOpenInvoice() : null - const decision = decidePostPaidFlow({ needsSetup, invoice }) + // Paid plans materialize an open invoice on create; resolvePostPaidFlow + // reconciles and decides whether to prompt the tenant to pay it, set up a + // payment method, or just navigate when autopay is already configured. + const decision = await resolvePostPaidFlow() switch (decision.kind) { case "pay_invoice": setPendingInvoice(decision.invoice)