forked from coracle/caravel
Fix small bugs
This commit is contained in:
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
Reference in New Issue
Block a user