Fix small bugs

This commit is contained in:
Jon Staab
2026-06-02 13:17:05 -07:00
parent b331a806ca
commit 4bd469fd17
11 changed files with 152 additions and 52 deletions
@@ -15,12 +15,14 @@ const invoiceStatusStyles: Record<string, string> = {
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) {
<div class="min-w-0">
<p class="font-medium text-gray-900">{formatUsd(props.invoice.amount)}</p>
<p class="text-xs text-gray-500">{formatPeriod(props.invoice.period_start, props.invoice.period_end)}</p>
<div class="mt-1.5 flex items-center gap-2">
<Show
when={getProfilePicture(metadata())}
fallback={
<div class="h-5 w-5 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{((metadata()?.name || metadata()?.display_name) || props.invoice.tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={getProfilePicture(metadata())} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="text-xs text-gray-500 truncate">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.invoice.tenant_pubkey)}</span>
</div>
<Show when={props.showTenant}>
<div class="mt-1.5 flex items-center gap-2">
<Show
when={getProfilePicture(metadata())}
fallback={
<div class="h-5 w-5 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{((metadata()?.name || metadata()?.display_name) || props.invoice.tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={getProfilePicture(metadata())} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="text-xs text-gray-500 truncate">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.invoice.tenant_pubkey)}</span>
</div>
</Show>
</div>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"}`}>
{status()}
+15 -2
View File
@@ -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 (
<div class="flex items-center gap-2 mb-6">
<A href={props.href} class="text-gray-500 hover:text-gray-700"> {props.label}</A>
<Show
when={props.href}
fallback={
<button type="button" onClick={() => history.back()} class="text-gray-500 hover:text-gray-700">
{props.label}
</button>
}
>
{(href) => <A href={href()} class="text-gray-500 hover:text-gray-700"> {props.label}</A>}
</Show>
</div>
)
}
+14
View File
@@ -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<Invoice | null> {
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<PaidFlowDecision> {
const needsSetup = await tenantNeedsPaymentSetup()
const invoice = needsSetup ? await getLatestOpenInvoice() : null
return decidePostPaidFlow({ needsSetup, invoice })
}
export type { Activity, Invoice, Relay, Tenant }
export type { ProfileContent }
+6 -8
View File
@@ -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)
+72 -10
View File
@@ -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<RelayFormValues>()
const [initialPlanId, setInitialPlanId] = createSignal<RelayFormValues["plan_id"]>("free")
const [pendingInvoice, setPendingInvoice] = createSignal<Invoice | undefined>()
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}
/>
</Modal>
<Show when={pendingInvoice()}>
{(inv) => (
<PaymentDialog
invoice={inv()}
open={true}
onClose={handleInvoiceClose}
/>
)}
</Show>
<PaymentSetup
open={paymentSetupOpen()}
onClose={handleSetupClose}
/>
</div>
)
}
@@ -26,7 +26,7 @@ export default function AdminInvoiceDetail() {
return (
<PageContainer>
<BackLink href="/admin/invoices" label="Invoices" />
<BackLink label="Back" />
<ResourceState loading={loading()} error={invoice.error} loadingText="Loading invoice..." errorText="Failed to load invoice." class="mb-4" />
<Show when={!loading() && invoice()}>
{(i) => (
@@ -39,7 +39,7 @@ export default function AdminInvoiceList() {
<Show when={filtered().length > 0} fallback={<p class="py-20 text-center text-gray-500">No invoices found.</p>}>
<ul class="space-y-3">
<For each={filtered()}>
{(invoice) => <AdminInvoiceListItem invoice={invoice} href={`/admin/invoices/${invoice.id}`} />}
{(invoice) => <AdminInvoiceListItem invoice={invoice} href={`/admin/invoices/${invoice.id}`} showTenant />}
</For>
</ul>
</Show>
@@ -29,7 +29,7 @@ export default function AdminRelayDetail() {
return (
<PageContainer>
<BackLink href="/admin/relays" label="Relays" />
<BackLink label="Back" />
<ResourceState loading={loading()} error={relay.error} loadingText="Loading relay..." errorText="Failed to load relay." class="mb-4" />
<Show when={!loading() && relay()}>
{(r) => (
+16 -4
View File
@@ -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 (
<PageContainer>
<BackLink href="/admin/tenants" label="Tenants" />
<BackLink label="Back" />
<div class="flex items-center gap-4 mb-6 py-2">
<Show
when={getProfilePicture(metadata())}
@@ -42,7 +44,7 @@ export default function AdminTenantDetail() {
<p class="text-xs text-gray-500 break-all">{tenantId()}</p>
</div>
</div>
<ResourceState loading={loading()} error={tenant.error || relays.error} loadingText="Loading tenant..." errorText="Failed to load tenant." class="mb-4" />
<ResourceState loading={loading()} error={tenant.error || relays.error || invoices.error} loadingText="Loading tenant..." errorText="Failed to load tenant." class="mb-4" />
<Show when={!loading()}>
<div class="space-y-6">
<section class="bg-white border border-gray-200 rounded-xl p-6">
@@ -96,6 +98,16 @@ export default function AdminTenantDetail() {
</ul>
</Show>
</section>
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4">Invoices</h2>
<Show when={(invoices()?.length ?? 0) > 0} fallback={<p class="text-gray-500">No invoices.</p>}>
<ul class="space-y-3">
<For each={invoices() ?? []}>
{(invoice) => <AdminInvoiceListItem invoice={invoice} href={`/admin/invoices/${invoice.id}`} />}
</For>
</ul>
</Show>
</section>
</div>
</Show>
</PageContainer>
+1 -1
View File
@@ -46,7 +46,7 @@ export default function RelayDetail() {
return (
<PageContainer>
<BackLink href="/relays" label="Relays" />
<BackLink label="Back" />
<ResourceState loading={loading()} error={relay.error} loadingText="Loading relay..." errorText="Failed to load relay." class="mb-4" />
<Show when={!loading() && relay()}>
{(r) => (
+5 -8
View File
@@ -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)