Frontend refactor

This commit is contained in:
Jon Staab
2026-06-01 17:57:06 -07:00
parent 08e59e3b40
commit bd5f4b1cd0
52 changed files with 1490 additions and 1073 deletions
+8 -2
View File
@@ -105,6 +105,12 @@ export type Tenant = {
churned_at: number | null
}
// Internal aliases derived from the wire shapes below — pure naming, no payload
// change. InvoiceMethod is the non-null members of Invoice.method; InvoiceStatus
// is the lifecycle status derived from the paid_at/voided_at timestamps.
export type InvoiceMethod = "nwc" | "stripe" | "oob"
export type InvoiceStatus = "open" | "paid" | "void"
export type Invoice = {
id: string
tenant_pubkey: string
@@ -114,7 +120,7 @@ export type Invoice = {
created_at: number
paid_at: number | null
voided_at: number | null
method: "nwc" | "stripe" | "oob" | null
method: InvoiceMethod | null
}
export type InvoiceItem = {
@@ -142,7 +148,7 @@ export type Bolt11 = {
// The backend models an invoice's lifecycle as timestamps rather than a status
// field, so derive the display status from them: paid once paid_at is set, void
// once voided_at is set, otherwise still open.
export function invoiceStatus(invoice: Pick<Invoice, "paid_at" | "voided_at">): "open" | "paid" | "void" {
export function invoiceStatus(invoice: Pick<Invoice, "paid_at" | "voided_at">): InvoiceStatus {
if (invoice.paid_at != null) return "paid"
if (invoice.voided_at != null) return "void"
return "open"
+12 -13
View File
@@ -1,12 +1,9 @@
import { createMemo, createSignal } from "solid-js"
import { createMemo } from "solid-js"
import { indexBy } from "@welshman/lib"
import { invoiceStatus, type Invoice, type Tenant } from "@/lib/api"
import { autopayConfigured, cardState, nwcState } from "@/lib/paymentMethod"
import { billingDraftInvoice, 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 = {
@@ -43,7 +40,7 @@ export function useBillingStatus() {
const balance = () => openInvoices().reduce((sum, inv) => sum + inv.amount, 0)
const hasPaidSubscription = createMemo(() => {
const planById = new Map(plans().map((p) => [p.id, p]))
const planById = indexBy((p) => p.id, plans())
return (billingRelays() ?? []).some((relay) => {
const plan = planById.get(relay.plan_id)
return Boolean(plan && plan.amount > 0 && relay.status === "active")
@@ -76,8 +73,10 @@ export function activeBillingPrompt(
}
}
const autopayConfigured = tenant.nwc_is_set || Boolean(tenant.stripe_payment_method_id)
const methodError = tenant.nwc_error ?? tenant.stripe_error
const hasAutopay = autopayConfigured(tenant)
const nwc = nwcState(tenant)
const card = cardState(tenant)
const methodError = nwc.kind === "error" || card.kind === "error"
const suppressInline = opts?.suppressInline ?? false
// Any open invoice gets a "Pay now" surface, even with autopay configured:
@@ -96,13 +95,13 @@ export function activeBillingPrompt(
return {
kind: "update_method",
severity: "warn",
message: tenant.nwc_error
message: nwc.kind === "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) {
if (s.hasPaidSubscription && !hasAutopay && !s.openInvoice && !suppressInline) {
return {
kind: "setup_autopay",
severity: "info",
@@ -134,9 +133,9 @@ export function accountStatus(s: BillingStatusSnapshot): AccountStatus {
if (!tenant) return "inactive"
if (tenant.churned_at) return "delinquent"
const autopayConfigured = tenant.nwc_is_set || Boolean(tenant.stripe_payment_method_id)
const hasAutopay = autopayConfigured(tenant)
const hasOpenInvoice = Boolean(s.openInvoice)
if (s.hasPaidSubscription || hasOpenInvoice || autopayConfigured) return "active"
if (s.hasPaidSubscription || hasOpenInvoice || hasAutopay) return "active"
return "inactive"
}
+16
View File
@@ -0,0 +1,16 @@
import { setToastMessage } from "@/lib/state"
export async function copyToClipboard(
text: string,
opts?: { successMessage?: string; errorMessage?: string },
): Promise<boolean> {
if (!text) return false
try {
await navigator.clipboard.writeText(text)
setToastMessage(opts?.successMessage ?? "Copied to clipboard", "success")
return true
} catch {
setToastMessage(opts?.errorMessage ?? "Couldn't copy to clipboard")
return false
}
}
+4
View File
@@ -0,0 +1,4 @@
export const formatUsd = (cents: number) => `$${(cents / 100).toFixed(2)}`
export const formatPeriod = (startSecs?: number, endSecs?: number) =>
(!startSecs || !endSecs) ? "" : `${new Date(startSecs * 1000).toLocaleDateString()} ${new Date(endSecs * 1000).toLocaleDateString()}`
+67 -20
View File
@@ -1,5 +1,8 @@
import { createEffect, createResource, createSignal, onCleanup } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { uniq } from "@welshman/lib"
import type { EventStore } from "applesauce-core"
import type { RelayPool } from "applesauce-relay"
import { getProfilePicture, type ProfileContent } from "applesauce-core/helpers/profile"
import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection"
import { includeMailboxes } from "applesauce-core/observable"
import { map, of } from "rxjs"
@@ -24,56 +27,99 @@ import {
type Tenant,
type UpdateRelayInput,
} from "@/lib/api"
import { autopayConfigured } from "@/lib/paymentMethod"
import { account, eventStore, pool } from "@/lib/state"
import { useNostr } from "@/lib/nostr"
export function useProfilePicture(pubkey: () => string | undefined) {
const [picture, setPicture] = createSignal<string | undefined>()
// Subscribes to the raw ProfileContent for a single pubkey from the event store,
// optionally priming it over the network. Call sites project the fields they need
// (name/display_name/nip05/picture) from the returned ProfileContent. Pass
// { prime: false } when a parent list already batch-primes these profiles.
export function useProfileMetadata(pubkey: () => string | undefined, opts?: { prime?: boolean }) {
// Safe: hooks run inside a component/root reactive scope, so useNostr resolves.
const nostr = useNostr()
const prime = opts?.prime ?? true
const [metadata, setMetadata] = createSignal<ProfileContent | undefined>()
createEffect(() => {
const pk = pubkey()
if (!pk) {
setPicture(undefined)
setMetadata(undefined)
return
}
const profileSub = eventStore.profile(pk).subscribe((profile) => {
setPicture(getProfilePicture(profile))
})
const reqSub = primeProfiles([pk])
const profileSub = nostr.eventStore.profile(pk).subscribe(setMetadata)
const reqSub = prime ? primeProfiles([pk], nostr) : undefined
onCleanup(() => {
profileSub.unsubscribe()
reqSub.unsubscribe()
reqSub?.unsubscribe()
})
})
return picture
return metadata
}
export function primeProfiles(pubkeys: string[]) {
const uniquePubkeys = Array.from(new Set(pubkeys.filter(Boolean)))
// Batch variant of useProfileMetadata: subscribes to a list of pubkeys, priming
// them all in one request, and returns a Record keyed by pubkey. Call sites
// project the fields they need from each ProfileContent.
export function useProfileMetadataMap(pubkeys: () => string[]) {
const nostr = useNostr()
const [metadata, setMetadata] = createSignal<Record<string, ProfileContent | undefined>>({})
createEffect(() => {
const list = pubkeys()
if (!list.length) return
const reqSub = primeProfiles(list, nostr)
const profileSubs = list.map((pubkey) =>
nostr.eventStore.profile(pubkey).subscribe((profile) => {
setMetadata((prev) => ({ ...prev, [pubkey]: profile }))
}),
)
onCleanup(() => {
reqSub.unsubscribe()
for (const sub of profileSubs) sub.unsubscribe()
})
})
return metadata
}
export function useProfilePicture(pubkey: () => string | undefined) {
const md = useProfileMetadata(pubkey)
return () => getProfilePicture(md())
}
// Accepts an optional context so callers inside a reactive scope can thread the
// injected eventStore/pool through; defaults to the module singletons because
// most callers run outside reactive scope (event handlers, plain effects) where
// useNostr() would be invalid.
export function primeProfiles(pubkeys: string[], ctx: { eventStore: EventStore; pool: RelayPool } = { eventStore, pool }) {
const { eventStore: store, pool: relayPool } = ctx
const uniquePubkeys = uniq(pubkeys.filter(Boolean))
if (uniquePubkeys.length === 0) {
return { unsubscribe() {} }
}
const seedRelays = Array.from(pool.relays.keys())
const seedRelays = Array.from(relayPool.relays.keys())
const mailboxSeedSub = seedRelays.length
? pool.request(seedRelays, { kinds: [10002], authors: uniquePubkeys }).subscribe((event) => {
eventStore.add(event)
? relayPool.request(seedRelays, { kinds: [10002], authors: uniquePubkeys }).subscribe((event) => {
store.add(event)
})
: undefined
const outboxMap$ = of(uniquePubkeys.map((pubkey) => ({ pubkey }))).pipe(
includeMailboxes(eventStore),
includeMailboxes(store),
map((pointers) => (seedRelays.length > 0 ? setFallbackRelays(pointers, seedRelays) : pointers)),
map((pointers) => selectOptimalRelays(pointers, { maxConnections: 8, maxRelaysPerUser: 3 })),
map(createOutboxMap),
)
const profileSub = pool.outboxSubscription(outboxMap$, { kinds: [0] }).subscribe((message) => {
if (message !== "EOSE") eventStore.add(message)
const profileSub = relayPool.outboxSubscription(outboxMap$, { kinds: [0] }).subscribe((message) => {
if (message !== "EOSE") store.add(message)
})
return {
@@ -136,7 +182,7 @@ export const reactivateRelayById = (id: string) => reactivateRelay(id)
export async function tenantNeedsPaymentSetup(): Promise<boolean> {
const tenant = await getTenant(account()!.pubkey)
return !tenant.nwc_is_set && !tenant.stripe_payment_method_id
return !autopayConfigured(tenant)
}
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
@@ -148,3 +194,4 @@ export async function getLatestOpenInvoice(): Promise<Invoice | null> {
}
export type { Activity, Invoice, Relay, Tenant }
export type { ProfileContent }
+56
View File
@@ -0,0 +1,56 @@
// Pure decision module for Login's input handling. No signers are constructed
// here (that's effectful/async and stays in the component) — only the input
// normalization, the key-material validation ladder, and the initial-tab choice.
// Keeping these pure makes them testable independently of the DOM/signer stack.
export type Tab = "nip07" | "nip46" | "key"
// Normalize a pasted signer link into a bunker:// URI. nostrconnect:// links are
// rewritten to the equivalent bunker:// form; anything else is passed through
// trimmed. Pure string transform.
export function normalizeBunkerUrl(value: string): string {
const trimmed = value.trim()
if (!trimmed) return ""
if (trimmed.startsWith("nostrconnect://")) {
const url = new URL(trimmed)
const remote = url.host || url.pathname.replace(/^\/+/, "")
const relays = url.searchParams.getAll("relay")
const secret = url.searchParams.get("secret")
const params = new URLSearchParams()
for (const relay of relays) params.append("relay", relay)
if (secret) params.set("secret", secret)
return `bunker://${remote}?${params.toString()}`
}
return trimmed
}
// Discriminated plan for key-material login, encoding the validation ladder:
// an ncryptsec requires a password; otherwise an nsec is required; otherwise it's
// an error. The component constructs the actual signer/account on the data
// branches and throws on the error branch.
export type KeyLoginPlan =
| { kind: "ncryptsec"; ncryptsec: string; password: string }
| { kind: "nsec"; key: string }
| { kind: "error"; message: string }
export function decideKeyLogin(input: { ncryptsec: string; nsec: string; password: string }): KeyLoginPlan {
const ncryptsec = input.ncryptsec.trim()
if (ncryptsec) {
if (!input.password.trim()) {
return { kind: "error", message: "Password is required for ncryptsec" }
}
return { kind: "ncryptsec", ncryptsec, password: input.password }
}
const key = input.nsec.trim()
if (!key) return { kind: "error", message: "Enter an nsec or ncryptsec key" }
return { kind: "nsec", key }
}
// Default login tab: prefer the extension when one is present, otherwise the
// remote signer tab.
export function initialLoginTab(hasExtension: boolean): Tab {
return hasExtension ? "nip07" : "nip46"
}
+35
View File
@@ -0,0 +1,35 @@
import { createComponent, createContext, useContext } from "solid-js"
import type { JSX } from "solid-js"
import type { IAccount } from "applesauce-accounts"
import type { EventStore } from "applesauce-core"
import type { RelayPool } from "applesauce-relay"
import { account, eventStore, pool } from "@/lib/state"
// A single lightweight DI seam for the app's nostr singletons. eventStore and
// pool are genuine app-lifetime singletons; account is a reactive signal. This
// context lets call sites reach them without importing the module globals
// directly, while falling back to the live singletons when no Provider is
// mounted — so production wiring stays byte-for-byte identical and the seam
// only matters for tests/alternate mounts.
export type NostrContext = {
account: () => IAccount | undefined
eventStore: EventStore
pool: RelayPool
}
const NostrContextImpl = createContext<NostrContext>()
export function useNostr(): NostrContext {
return useContext(NostrContextImpl) ?? { account, eventStore, pool }
}
// Authored without JSX so this stays a plain .ts module. createComponent renders
// the context Provider with the given value, wrapping the children.
export function NostrProvider(props: { value: NostrContext; children?: JSX.Element }): JSX.Element {
return createComponent(NostrContextImpl.Provider, {
value: props.value,
get children() {
return props.children
},
})
}
+40
View File
@@ -0,0 +1,40 @@
import type { InvoiceMethod, Tenant } from "@/lib/api"
// A discriminated view of a single payment method's state, derived AT the
// boundary from the raw tenant fields (nwc_is_set/stripe_payment_method_id and
// the matching *_error). This replaces the ad-hoc per-page MethodState objects
// and the scattered nwc/stripe boolean expressions so the surfaces can't drift.
// No new wire data is emitted: these read the same raw fields the API returns.
export type PaymentMethodState =
| { kind: "not_set_up" }
| { kind: "ok" }
| { kind: "error"; message: string }
export function nwcState(t: Pick<Tenant, "nwc_is_set" | "nwc_error">): PaymentMethodState {
if (!t.nwc_is_set) return { kind: "not_set_up" }
if (t.nwc_error) return { kind: "error", message: t.nwc_error }
return { kind: "ok" }
}
export function cardState(t: Pick<Tenant, "stripe_payment_method_id" | "stripe_error">): PaymentMethodState {
if (!t.stripe_payment_method_id) return { kind: "not_set_up" }
if (t.stripe_error) return { kind: "error", message: t.stripe_error }
return { kind: "ok" }
}
// True when the tenant has any usable automatic payment method on file.
export function autopayConfigured(
t: Pick<Tenant, "nwc_is_set" | "stripe_payment_method_id">,
): boolean {
return t.nwc_is_set || Boolean(t.stripe_payment_method_id)
}
const METHOD_LABELS: Record<InvoiceMethod, string> = {
nwc: "Lightning",
stripe: "Card",
oob: "Lightning",
}
export function methodLabel(m: InvoiceMethod): string {
return METHOD_LABELS[m]
}
+3
View File
@@ -0,0 +1,3 @@
export function shortenPubkey(pubkey: string) {
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
+15
View File
@@ -0,0 +1,15 @@
// Relay feature/policy flags are stored on the wire as numeric 0/1 (see
// Relay/CreateRelayInput/UpdateRelayInput in api.ts). These helpers centralize
// the boolean<->0/1 conversion so it isn't duplicated across the toggle UI and
// the toggle mutations. The wire shape stays numeric: boolToFlag returns the
// literal union `0 | 1` so it remains assignable to the `number` input fields.
export function flagToBool(value: number | undefined, fallback: boolean): boolean {
if (value === 0) return false
if (value === 1) return true
return fallback
}
export function boolToFlag(value: boolean): 0 | 1 {
return value ? 1 : 0
}
+56
View File
@@ -0,0 +1,56 @@
// Pure decision module shared by useRelayToggles and RelayNew. No Solid signals,
// no awaits, no effects live here — only the decisions (payload shape, optimistic
// copy shape, the toggle next-relay computation, and the needs-setup -> invoice ->
// setup ladder). The effect layers (mutate, awaits, signal writes, navigate) stay
// in the hook/components that import these. Keeping the decisions pure makes the
// plan-upgrade and relay-creation flows testable and dedupes the post-paid ladder
// that RelayNew and handleUpdatePlan previously inlined separately.
import { flagToBool, boolToFlag } from "@/lib/relayFlags"
import type { Invoice, PlanId, Relay, UpdateRelayInput } from "@/lib/api"
// CRITICAL: the returned object is the JSON request payload sent to PUT /relays.
// Keep exactly these field names/values so the wire stays byte-identical to the
// previous inline payload: `{ plan_id }` for paid plans, plus blossom/livekit
// disable flags for free.
export function planUpdatePayload(plan_id: PlanId): UpdateRelayInput {
if (plan_id === "free") {
return { plan_id, blossom_enabled: 0, livekit_enabled: 0 }
}
return { plan_id }
}
// The optimistic local copy passed to mutate(). Internal-only type, mirrors the
// payload's effect on the relay row. Free downgrades also clear the paid feature
// flags so the optimistic view matches what the server will persist.
export function applyPlanToRelay(relay: Relay, plan_id: PlanId): Relay {
if (plan_id === "free") {
return { ...relay, plan_id, blossom_enabled: 0, livekit_enabled: 0 }
}
return { ...relay, plan_id }
}
// Pure next-relay computation for a single boolean flag toggle, extracted from
// useRelayToggles. The flag is stored as 0/1 on the wire, so flip the derived
// boolean and convert back.
export function toggleField(relay: Relay, field: keyof Relay, fallback: boolean): Relay {
return { ...relay, [field]: boolToFlag(!flagToBool(relay[field] as number, fallback)) }
}
// Discriminated decision for what to do after a paid create/upgrade succeeds and
// the tenant's payment-setup state has been resolved. Internal/derived — never
// serialized.
export type PaidFlowDecision =
| { kind: "navigate" }
| { kind: "pay_invoice"; invoice: Invoice }
| { kind: "setup" }
// The exact ladder both RelayNew's post-create branch and handleUpdatePlan's
// post-upgrade branch inline: if the tenant already has autopay configured, just
// navigate; otherwise surface the materialized open invoice to pay directly, or
// fall back to payment setup when none is available.
export function decidePostPaidFlow(args: { needsSetup: boolean; invoice: Invoice | null }): PaidFlowDecision {
if (!args.needsSetup) return { kind: "navigate" }
if (args.invoice) return { kind: "pay_invoice", invoice: args.invoice }
return { kind: "setup" }
}
+10
View File
@@ -0,0 +1,10 @@
import Fuse from "fuse.js"
export const FUSE_THRESHOLD = 0.35
export function fuzzySearch<T>(list: T[], keys: string[], query: string): T[] {
if (!query) return list
return new Fuse(list, {keys, threshold: FUSE_THRESHOLD, ignoreLocation: true})
.search(query)
.map(result => result.item)
}
+41 -6
View File
@@ -46,6 +46,21 @@ export const [account, setAccount] = createSignal<IAccount | undefined>()
registerAccountGetter(account)
export type ToastVariant = "error" | "success"
export const [toastVariant, setToastVariant] = createSignal<ToastVariant>("error")
export const [toastMessage, setRawToastMessage] = createSignal("")
export function setToastMessage(message: string, variant: ToastVariant = "error") {
setToastVariant(variant)
setRawToastMessage(message)
}
// 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 const [plans] = createResource<Plan[]>(listPlans, { initialValue: [] })
export const [identity, { refetch: refetchIdentity, mutate: setIdentity }] = createResource(
@@ -72,10 +87,18 @@ export const [billingRelays, { refetch: refetchBillingRelays }] = createResource
export const [billingDraftInvoice, { refetch: refetchBillingDraftInvoice }] = createResource(billingKey, getDraftInvoice)
export function refetchBilling() {
void refetchBillingTenant()
void refetchBillingInvoices()
void refetchBillingRelays()
void refetchBillingDraftInvoice()
void Promise.allSettled([
refetchBillingTenant(),
refetchBillingInvoices(),
refetchBillingRelays(),
refetchBillingDraftInvoice(),
]).then(results => {
if (results.some(r => r.status === "rejected")) {
const err = results.find(r => r.status === "rejected") as PromiseRejectedResult | undefined
console.error("Failed to refresh billing data", err?.reason)
setToastMessage("Failed to refresh billing data")
}
})
}
// Ensure the active pubkey's tenant row exists, then unlock billing reads. The
@@ -117,7 +140,13 @@ queueMicrotask(() => {
accountManager.setActive(active)
}
accountManager.active$.subscribe(account => {
// Held for the whole app session: this callback persists accounts to
// localStorage and ensures the session tenant on every switch, so it must
// never be torn down by a component lifecycle. The only teardown is the HMR
// dispose hook below, which prevents a Vite hot-update from stacking a
// duplicate persisting subscriber during dev (production uses /* @refresh
// reload */, so it never runs there).
const accountSubscription = accountManager.active$.subscribe(account => {
setAccount(account)
localStorage.setItem("caravel.accounts", JSON.stringify(accountManager.toJSON(true)))
@@ -133,6 +162,12 @@ queueMicrotask(() => {
// Lock billing reads until the new account's tenant is ensured, so they never
// fire against a not-yet-provisioned tenant during signup.
setBillingPubkey(undefined)
if (account) void ensureSessionTenant().catch(() => {})
if (account)
void ensureSessionTenant().catch(e => {
console.error("Failed to ensure tenant", e)
setToastMessage(e instanceof Error ? e.message : "Failed to set up your billing account")
})
})
if (import.meta.hot) import.meta.hot.dispose(() => accountSubscription.unsubscribe())
})
+2
View File
@@ -1,3 +1,5 @@
export const RELAY_DOMAIN = import.meta.env.VITE_RELAY_DOMAIN
const SUBDOMAIN_LABEL_MAX_LEN = 63
const RESERVED_SUBDOMAIN_LABELS = new Set(["api", "admin"])
+10 -12
View File
@@ -1,15 +1,10 @@
import { createSignal } from "solid-js"
import QRCode from "qrcode"
import { getInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api"
import { methodLabel } from "@/lib/paymentMethod"
import { formatUsd } from "@/lib/format"
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) {
@@ -32,7 +27,10 @@ export function useInvoicePdf() {
setPrinting(true)
try {
const fetchItems = loadItems ?? (() => listInvoiceItems(invoice.id))
const items = await fetchItems().catch(() => [] as InvoiceItem[])
const items = await fetchItems().catch(e => {
console.error("Failed to load invoice line items", e)
return [] as InvoiceItem[]
})
let sats: number | undefined
let qrDataUrl: string | undefined
@@ -65,12 +63,12 @@ function buildHtml(opts: { invoice: Invoice; items: InvoiceItem[]; sats?: number
const rows = items.length
? items
.map((i) => `<tr><td>${escapeHtml(i.description || "Charge")}</td><td class="amt">${fmtUsd(i.amount)}</td></tr>`)
.map((i) => `<tr><td>${escapeHtml(i.description || "Charge")}</td><td class="amt">${formatUsd(i.amount)}</td></tr>`)
.join("")
: `<tr><td>Relay subscription</td><td class="amt">${fmtUsd(invoice.amount)}</td></tr>`
: `<tr><td>Relay subscription</td><td class="amt">${formatUsd(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 methodLine = invoice.method ? `<div>Paid via ${escapeHtml(methodLabel(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>`
: ""
@@ -105,7 +103,7 @@ function buildHtml(opts: { invoice: Invoice; items: InvoiceItem[]; sats?: number
<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>
<tfoot><tr><td>Total</td><td class="amt">${formatUsd(invoice.amount)}</td></tr></tfoot>
</table>
${qr}
</body></html>`
+41
View File
@@ -0,0 +1,41 @@
import { createEffect, createSignal, onCleanup } from "solid-js"
export default function useMinLoading(loading: () => boolean, minDurationMs = 200) {
const [visible, setVisible] = createSignal(false)
let startTime = 0
let timeout: number | undefined
createEffect(() => {
if (timeout) {
window.clearTimeout(timeout)
timeout = undefined
}
if (loading()) {
startTime = Date.now()
setVisible(true)
return
}
if (!visible()) return
const elapsed = Date.now() - startTime
const remaining = Math.max(minDurationMs - elapsed, 0)
if (remaining === 0) {
setVisible(false)
return
}
timeout = window.setTimeout(() => {
setVisible(false)
timeout = undefined
}, remaining)
onCleanup(() => {
if (timeout) window.clearTimeout(timeout)
})
})
return visible
}
+22 -34
View File
@@ -1,18 +1,9 @@
import { createSignal } from "solid-js"
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
import { setToastMessage } from "@/components/Toast"
import { setToastMessage } from "@/lib/state"
import { applyPlanToRelay, decidePostPaidFlow, planUpdatePayload, toggleField } from "@/lib/relayPlanFlow"
import type { Invoice, PlanId } from "@/lib/api"
function toBool(value: number | undefined, fallback: boolean): boolean {
if (value === 0) return false
if (value === 1) return true
return fallback
}
function toInt(value: boolean): number {
return value ? 1 : 0
}
type RelayResource = {
(): Relay | undefined
loading: boolean
@@ -47,8 +38,7 @@ export default function useRelayToggles(
function toggle(field: keyof Relay, fallback: boolean) {
const current = relay()
if (!current) return
const next = { ...current, [field]: toInt(!toBool(current[field] as number, fallback)) }
void updateRelay(next, current)
void updateRelay(toggleField(current, field, fallback), current)
}
async function handleDeactivate() {
@@ -82,18 +72,10 @@ export default function useRelayToggles(
if (!current) return
const previous = current
const next = { ...current, plan_id }
const update: Record<string, unknown> = { plan_id }
if (plan_id === "free") {
next.blossom_enabled = 0
next.livekit_enabled = 0
update.blossom_enabled = 0
update.livekit_enabled = 0
}
mutate(next)
mutate(applyPlanToRelay(current, plan_id))
try {
await updateRelayById(relayId(), update)
await updateRelayById(relayId(), planUpdatePayload(plan_id))
await refetch()
} catch (e) {
mutate(previous)
@@ -101,18 +83,24 @@ export default function useRelayToggles(
throw e
}
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 (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 })
switch (decision.kind) {
case "pay_invoice":
setPendingInvoice(decision.invoice)
setPendingPaymentSetup(true)
}
break
case "setup":
setPendingPaymentSetup(true)
break
case "navigate":
break
}
}
+19
View File
@@ -0,0 +1,19 @@
export function isKnownPlanId(planId: string, plans: { id: string }[]): boolean {
return plans.some(p => p.id === planId)
}
export function validateBunkerUri(value: string): string | null {
const trimmed = value.trim()
if (!trimmed) return "Enter a bunker or nostrconnect link"
if (trimmed.startsWith("nostrconnect://") || trimmed.startsWith("bunker://")) {
try {
new URL(trimmed)
} catch {
return "That doesn't look like a valid link"
}
return null
}
return "Link must start with bunker:// or nostrconnect://"
}