Frontend refactor
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function shortenPubkey(pubkey: string) {
|
||||
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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://"
|
||||
}
|
||||
Reference in New Issue
Block a user