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
+17 -14
View File
@@ -14,7 +14,8 @@ import AdminTenantDetail from "@/pages/admin/AdminTenantDetail"
import AdminRelayList from "@/pages/admin/AdminRelayList"
import AdminRelayDetail from "@/pages/admin/AdminRelayDetail"
import AdminRelayEdit from "@/pages/admin/AdminRelayEdit"
import { identity } from "@/lib/state"
import { account, eventStore, identity, pool } from "@/lib/state"
import { NostrProvider } from "@/lib/nostr"
function Layout(props: { children?: any }) {
const location = useLocation()
@@ -58,18 +59,20 @@ export default function App() {
const requireTenant = (Page: Component) => requireCondition(Page, () => Boolean(identity()))
return (
<Router root={Layout}>
<Route path="/" component={Home} />
<Route path="/relays" component={requireTenant(RelayList)} />
<Route path="/relays/new" component={requireTenant(RelayNew)} />
<Route path="/relays/:id" component={requireTenant(RelayDetail)} />
<Route path="/relays/:id/edit" component={requireTenant(RelayEdit)} />
<Route path="/account" component={requireTenant(Account)} />
<Route path="/admin/tenants" component={requireAdmin(AdminTenantList)} />
<Route path="/admin/tenants/:id" component={requireAdmin(AdminTenantDetail)} />
<Route path="/admin/relays" component={requireAdmin(AdminRelayList)} />
<Route path="/admin/relays/:id" component={requireAdmin(AdminRelayDetail)} />
<Route path="/admin/relays/:id/edit" component={requireAdmin(AdminRelayEdit)} />
</Router>
<NostrProvider value={{ account, eventStore, pool }}>
<Router root={Layout}>
<Route path="/" component={Home} />
<Route path="/relays" component={requireTenant(RelayList)} />
<Route path="/relays/new" component={requireTenant(RelayNew)} />
<Route path="/relays/:id" component={requireTenant(RelayDetail)} />
<Route path="/relays/:id/edit" component={requireTenant(RelayEdit)} />
<Route path="/account" component={requireTenant(Account)} />
<Route path="/admin/tenants" component={requireAdmin(AdminTenantList)} />
<Route path="/admin/tenants/:id" component={requireAdmin(AdminTenantDetail)} />
<Route path="/admin/relays" component={requireAdmin(AdminRelayList)} />
<Route path="/admin/relays/:id" component={requireAdmin(AdminRelayDetail)} />
<Route path="/admin/relays/:id/edit" component={requireAdmin(AdminRelayEdit)} />
</Router>
</NostrProvider>
)
}
+10 -47
View File
@@ -1,18 +1,13 @@
import { A, useLocation } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import Fuse from "fuse.js"
import { primeProfiles, useProfilePicture, useTenantRelays, type Relay } from "@/lib/hooks"
import { account, eventStore, identity } from "@/lib/state"
import { createMemo, createSignal, For, Show } from "solid-js"
import { useProfileMetadata, useProfilePicture, useTenantRelays, type Relay } from "@/lib/hooks"
import { account, identity } from "@/lib/state"
import { fuzzySearch } from "@/lib/search"
import { RELAY_DOMAIN } from "@/lib/subdomain"
import serverIcon from "@/assets/server.svg"
import Modal from "@/components/Modal"
import BillingPrompts from "@/components/BillingPrompts"
type Profile = {
name?: string
display_name?: string
nip05?: string
}
function shortenPubkey(pubkey?: string) {
if (!pubkey) return ""
return `${pubkey.slice(0, 8)}${pubkey.slice(-6)}`
@@ -34,50 +29,18 @@ function RelayIcon() {
export default function AppShell(props: { children?: any }) {
const location = useLocation()
const picture = useProfilePicture(() => account()?.pubkey)
const metadata = useProfileMetadata(() => account()?.pubkey)
const [tenantRelays] = useTenantRelays()
const [profile, setProfile] = createSignal<Profile>({})
const [searchOpen, setSearchOpen] = createSignal(false)
const [searchQuery, setSearchQuery] = createSignal("")
const username = createMemo(() => profile().name || profile().display_name || shortenPubkey(account()?.pubkey))
const nip05 = createMemo(() => profile().nip05 || "No NIP-05")
const username = createMemo(() => metadata()?.name || metadata()?.display_name || shortenPubkey(account()?.pubkey))
const nip05 = createMemo(() => metadata()?.nip05)
const searchedRelays = createMemo<Relay[]>(() => {
const list = tenantRelays() ?? []
const query = searchQuery().trim()
if (!query) return list
const fuse = new Fuse(list, {
keys: ["info_name", "subdomain"],
threshold: 0.35,
ignoreLocation: true,
})
return fuse.search(query).map((result) => result.item)
})
createEffect(() => {
const pubkey = account()?.pubkey
if (!pubkey) {
setProfile({})
return
}
const profileSub = eventStore.profile(pubkey).subscribe((metadata) => {
setProfile({
name: metadata?.name,
display_name: metadata?.display_name,
nip05: metadata?.nip05,
})
})
const primeSub = primeProfiles([pubkey])
onCleanup(() => {
profileSub.unsubscribe()
primeSub.unsubscribe()
})
return fuzzySearch(list, ["info_name", "subdomain"], query)
})
const myResources = [{ href: "/relays", label: "My Relays" }]
@@ -224,7 +187,7 @@ export default function AppShell(props: { children?: any }) {
onClick={closeSearchModal}
>
<p class="text-sm font-medium text-gray-900">{relay.info_name || relay.subdomain}</p>
<p class="text-xs text-gray-500">{relay.subdomain}.spaces.coracle.social</p>
<p class="text-xs text-gray-500">{relay.subdomain}.{RELAY_DOMAIN}</p>
</A>
</li>
)}
+3 -2
View File
@@ -4,7 +4,8 @@ import PromptBanner, { type PromptBannerAction } from "@/components/PromptBanner
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, type Invoice } from "@/lib/api"
import { activeBillingPrompt, billingFlowActive, useBillingStatus, type BillingPromptKind } from "@/lib/billing"
import { activeBillingPrompt, useBillingStatus, type BillingPromptKind } from "@/lib/billing"
import { billingFlowActive } from "@/lib/state"
type BillingPromptsProps = {
// "banner" sits in the dashboard shell (mounted on every page except the
@@ -76,7 +77,7 @@ export default function BillingPrompts(props: BillingPromptsProps) {
function dismiss() {
const p = visiblePrompt()
if (p) setDismissed((prev) => new Set(prev).add(p.kind))
if (p) setDismissed((prev) => new Set([...prev, p.kind]))
}
function clearDeepLink() {
+22 -70
View File
@@ -1,12 +1,17 @@
import { createEffect, createResource, createSignal, For, Show } from "solid-js"
import { createEffect, createResource, createSignal, Show } from "solid-js"
import QRCode from "qrcode"
import Modal from "@/components/Modal"
import PaymentSetup from "@/components/PaymentSetup"
import { CardSetupBody } from "@/components/PaymentSetupShell"
import { setToastMessage } from "@/components/Toast"
import InvoiceItemsList from "@/components/payment/InvoiceItemsList"
import LightningPayBody from "@/components/payment/LightningPayBody"
import { setToastMessage } from "@/lib/state"
import { copyToClipboard } from "@/lib/clipboard"
import { useCardPortal } from "@/lib/usePaymentSetup"
import { getInvoice, getInvoiceBolt11, listInvoiceItems, type Invoice } from "@/lib/api"
import { autopayConfigured } from "@/lib/paymentMethod"
import { billingTenant } from "@/lib/state"
import { formatUsd, formatPeriod } from "@/lib/format"
type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
@@ -39,9 +44,9 @@ export default function PaymentDialog(props: PaymentDialogProps) {
// file we retry collection on this invoice automatically.
const card = useCardPortal()
const autopayConfigured = () => {
const hasAutopay = () => {
const t = billingTenant()
return Boolean(t?.nwc_is_set || t?.stripe_payment_method_id)
return t ? autopayConfigured(t) : false
}
async function loadBolt11() {
@@ -75,7 +80,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
})
function copyBolt11() {
void navigator.clipboard.writeText(bolt11())
void copyToClipboard(bolt11(), { successMessage: "Invoice copied" })
}
async function checkPayment() {
@@ -105,15 +110,9 @@ export default function PaymentDialog(props: PaymentDialogProps) {
props.onClose()
}
const amountLabel = () => `$${(props.invoice.amount / 100).toFixed(2)}`
const amountLabel = () => formatUsd(props.invoice.amount)
const periodLabel = () => {
const { period_start, period_end } = props.invoice
if (!period_start || !period_end) return ""
const start = new Date(period_start * 1000).toLocaleDateString()
const end = new Date(period_end * 1000).toLocaleDateString()
return `${start} ${end}`
}
const periodLabel = () => formatPeriod(props.invoice.period_start, props.invoice.period_end)
return (
<>
@@ -154,19 +153,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
<div class="w-full space-y-4">
{/* What's being paid for — the invoice's actual line items */}
<Show when={(items() ?? []).length > 0}>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">On this invoice</p>
<ul class="space-y-1.5">
<For each={items()}>
{(item) => (
<li class="flex items-center justify-between gap-3 text-sm">
<span class="truncate text-gray-900">{item.description}</span>
<span class="flex-shrink-0 text-xs text-gray-500">${(item.amount / 100).toFixed(2)}</span>
</li>
)}
</For>
</ul>
</div>
<InvoiceItemsList items={items() ?? []} />
</Show>
{/* Method switcher */}
@@ -189,49 +176,14 @@ export default function PaymentDialog(props: PaymentDialogProps) {
{/* Lightning: pay this invoice via a bolt11 QR */}
<Show when={payMethod() === "lightning"}>
<Show when={bolt11Status() === "idle" || bolt11Status() === "loading"}>
<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>
</Show>
<Show when={bolt11Status() === "error"}>
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm font-medium text-red-700">Unable to generate invoice</p>
<p class="mt-1 text-xs text-red-600 wrap-break-word">{bolt11Error()}</p>
<button
type="button"
onClick={() => void loadBolt11()}
class="mt-3 inline-flex items-center rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
>
Retry
</button>
</div>
</Show>
<Show when={bolt11Status() === "ready"}>
<img src={qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
<Show when={bolt11()}>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={bolt11()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={copyBolt11}
title="Copy invoice"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button>
</div>
</Show>
<p class="text-xs text-gray-500 text-center">
Scan this QR code with a Bitcoin Lightning wallet to pay.
</p>
</Show>
<LightningPayBody
bolt11Status={bolt11Status}
bolt11={bolt11}
qrDataUrl={qrDataUrl}
bolt11Error={bolt11Error}
onRetry={() => void loadBolt11()}
onCopy={copyBolt11}
/>
</Show>
{/* Card: redirect to the Stripe billing portal */}
@@ -249,7 +201,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</div>
<p class="text-sm font-medium text-gray-900">Payment confirmed!</p>
<p class="text-xs text-gray-500">Thank you. Your account is up to date.</p>
<Show when={!autopayConfigured()}>
<Show when={!hasAutopay()}>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
+42 -236
View File
@@ -1,35 +1,16 @@
import { A } from "@solidjs/router"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { Show, createSignal } from "solid-js"
import type { Relay, PlanId } from "@/lib/api"
import menuDotsIcon from "@/assets/menu-dots-2.svg"
import ConfirmDialog from "@/components/ConfirmDialog"
import Field from "@/components/Field"
import PricingTable from "@/components/PricingTable"
import ToggleButton from "@/components/ToggleButton"
import ToggleField from "@/components/ToggleField"
import { setToastMessage } from "@/components/Toast"
import { primeProfiles } from "@/lib/hooks"
import { eventStore, plans } from "@/lib/state"
function shortenPubkey(pubkey: string) {
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
const STATUS_STYLES: Record<string, string> = {
active: "bg-green-50 text-green-700 border-green-200",
inactive: "bg-gray-100 text-gray-500 border-gray-200",
}
function StatusBadge(props: { status: string }) {
const styles = () => STATUS_STYLES[props.status] ?? "bg-gray-100 text-gray-500 border-gray-200"
const label = () => props.status.replace(/_/g, " ")
return (
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${styles()}`}>
{label()}
</span>
)
}
import RelayCardHeader from "@/components/relay/RelayCardHeader"
import PlanGatedToggle from "@/components/relay/PlanGatedToggle"
import { setToastMessage } from "@/lib/state"
import { useProfileMetadata } from "@/lib/hooks"
import { flagToBool } from "@/lib/relayFlags"
import { plans } from "@/lib/state"
function DetailSection(props: { title: string; children: any }) {
return (
@@ -76,38 +57,13 @@ type RelayDetailCardProps = {
export default function RelayDetailCard(props: RelayDetailCardProps) {
const r = () => props.relay
const flag = (value: number, fallback: boolean) => {
if (value === 0) return false
if (value === 1) return true
return fallback
}
const [menuOpen, setMenuOpen] = createSignal(false)
const [planId, setPlanId] = createSignal<PlanId>(props.relay.plan_id)
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
const [tenantProfile, setTenantProfile] = createSignal<{ name?: string; picture?: string }>({})
// Resolve the owning tenant's profile so the Tenant field can show a name and
// avatar instead of a raw pubkey. Only relevant in admin (showTenant) views.
createEffect(() => {
if (!props.showTenant) return
const pubkey = props.relay.tenant_pubkey
if (!pubkey) return
const sub = eventStore.profile(pubkey).subscribe((metadata) => {
setTenantProfile({
name: metadata?.name || metadata?.display_name,
picture: getProfilePicture(metadata),
})
})
const primeSub = primeProfiles([pubkey])
onCleanup(() => {
sub.unsubscribe()
primeSub.unsubscribe()
})
})
let menuContainerRef: HTMLDivElement | undefined
// This subscription stays in the parent so the header doesn't double-subscribe.
const tenantProfile = useProfileMetadata(() => (props.showTenant ? props.relay.tenant_pubkey : undefined))
const memberLimitLabel = () => {
const p = plans().find(p => p.id === r().plan_id)
@@ -146,7 +102,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
}
function openActionDialog(action: "deactivate" | "reactivate") {
setMenuOpen(false)
setPendingAction(action)
}
@@ -168,144 +123,31 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
setPendingAction(null)
}
createEffect(() => {
if (!menuOpen()) return
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null
if (target && !menuContainerRef?.contains(target)) {
setMenuOpen(false)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMenuOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
document.addEventListener("keydown", handleEscape)
onCleanup(() => {
document.removeEventListener("mousedown", handleClickOutside)
document.removeEventListener("keydown", handleEscape)
})
})
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 min-w-0">
<Show when={r().info_icon}>
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover shrink-0 border border-gray-200" />
</Show>
<div class="min-w-0">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-gray-900">{r().info_name || r().subdomain}</h1>
<StatusBadge status={r().status} />
</div>
<a
href={`wss://${r().subdomain}.spaces.coracle.social`}
class="text-sm text-blue-600 hover:underline break-all"
>
wss://{r().subdomain}.spaces.coracle.social
</a>
<Show when={props.showTenant}>
<A href={`/admin/tenants/${r().tenant_pubkey}`} class="group mt-1.5 flex w-fit items-center gap-2 min-w-0">
<Show
when={tenantProfile().picture}
fallback={
<div class="h-6 w-6 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{(tenantProfile().name || r().tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={tenantProfile().picture} alt="" class="h-6 w-6 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="text-sm text-blue-600 group-hover:underline truncate">{tenantProfile().name || shortenPubkey(r().tenant_pubkey)}</span>
</A>
</Show>
<Show when={r().info_description.trim()}>
<p class="mt-2 text-sm text-gray-600">{r().info_description}</p>
</Show>
</div>
</div>
<Show when={props.editHref && (props.onDeactivate || props.onReactivate)}>
<div class="relative shrink-0" ref={menuContainerRef}>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50"
aria-label="Open relay actions"
onClick={() => setMenuOpen((open) => !open)}
>
<img src={menuDotsIcon} alt="" class="h-5 w-5" />
</button>
<div
class="absolute right-0 mt-2 w-44 rounded-lg border border-gray-200 bg-white shadow-lg z-10 py-1 origin-top-right transition-all duration-150 ease-out"
classList={{
"opacity-100 scale-100": menuOpen(),
"opacity-0 scale-95 pointer-events-none": !menuOpen(),
}}
>
<A
href={props.editHref!}
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => setMenuOpen(false)}
>
Edit Details
</A>
<Show when={r().status === "active" && props.onDeactivate}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
onClick={() => {
openActionDialog("deactivate")
}}
disabled={props.deactivating}
>
{props.deactivating ? "Deactivating..." : "Deactivate"}
</button>
</Show>
<Show when={r().status === "inactive" && props.onReactivate}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50"
onClick={() => {
openActionDialog("reactivate")
}}
disabled={props.reactivating}
>
{props.reactivating ? "Reactivating..." : "Reactivate"}
</button>
</Show>
</div>
</div>
</Show>
</div>
<Show when={r().sync_error}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p class="text-sm font-semibold text-red-800">Provisioning error</p>
<p class="mt-1 text-sm text-red-700 font-mono break-all">{r().sync_error}</p>
</div>
</Show>
<RelayCardHeader
relay={r}
showTenant={props.showTenant}
tenantProfile={tenantProfile}
editHref={props.editHref}
deactivating={props.deactivating}
reactivating={props.reactivating}
onRequestDeactivate={props.onDeactivate ? () => openActionDialog("deactivate") : undefined}
onRequestReactivate={props.onReactivate ? () => openActionDialog("reactivate") : undefined}
/>
<hr class="border-gray-200" />
<DetailSection title="Policy">
<ToggleField label="Public join">
<ToggleButton
enabled={flag(r().policy_public_join, false)}
enabled={flagToBool(r().policy_public_join, false)}
onToggle={props.onTogglePublicJoin}
/>
</ToggleField>
<ToggleField label="Strip signatures">
<ToggleButton
enabled={flag(r().policy_strip_signatures, false)}
enabled={flagToBool(r().policy_strip_signatures, false)}
onToggle={props.onToggleStripSignatures}
/>
</ToggleField>
@@ -316,79 +158,43 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
<DetailSection title="Features">
<ToggleField label="Rooms">
<ToggleButton
enabled={flag(r().groups_enabled, true)}
enabled={flagToBool(r().groups_enabled, true)}
onToggle={props.onToggleGroups}
/>
</ToggleField>
<ToggleField label="Management API">
<ToggleButton
enabled={flag(r().management_enabled, true)}
enabled={flagToBool(r().management_enabled, true)}
onToggle={props.onToggleManagement}
/>
</ToggleField>
<ToggleField label="Push notifications">
<ToggleButton
enabled={flag(r().push_enabled, true)}
enabled={flagToBool(r().push_enabled, true)}
onToggle={props.onTogglePushNotifications}
/>
</ToggleField>
<ToggleField label="Media storage">
<Show
when={!planLimited()}
fallback={
<Show when={showPlanActions()} fallback={<ToggleButton enabled={flag(r().blossom_enabled, false)} onToggle={props.onToggleMediaStorage} />}>
<Show
when={props.onUpdatePlan}
fallback={
<A
href={props.editHref ?? "#"}
class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
>
Upgrade Plan
</A>
}
>
<span class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700">
Update Plan
</span>
</Show>
</Show>
}
>
<ToggleButton
enabled={flag(r().blossom_enabled, true)}
onToggle={props.onToggleMediaStorage}
/>
</Show>
<PlanGatedToggle
enabled={flagToBool(r().blossom_enabled, true)}
fallbackEnabled={flagToBool(r().blossom_enabled, false)}
planLimited={planLimited()}
showPlanActions={showPlanActions()}
canUpdatePlan={!!props.onUpdatePlan}
editHref={props.editHref}
onToggle={props.onToggleMediaStorage}
/>
</ToggleField>
<ToggleField label="LiveKit support">
<Show
when={!planLimited()}
fallback={
<Show when={showPlanActions()} fallback={<ToggleButton enabled={flag(r().livekit_enabled, false)} onToggle={props.onToggleLivekitSupport} />}>
<Show
when={props.onUpdatePlan}
fallback={
<A
href={props.editHref ?? "#"}
class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
>
Upgrade Plan
</A>
}
>
<span class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700">
Update Plan
</span>
</Show>
</Show>
}
>
<ToggleButton
enabled={flag(r().livekit_enabled, true)}
onToggle={props.onToggleLivekitSupport}
/>
</Show>
<PlanGatedToggle
enabled={flagToBool(r().livekit_enabled, true)}
fallbackEnabled={flagToBool(r().livekit_enabled, false)}
planLimited={planLimited()}
showPlanActions={showPlanActions()}
canUpdatePlan={!!props.onUpdatePlan}
editHref={props.editHref}
onToggle={props.onToggleLivekitSupport}
/>
</ToggleField>
</DetailSection>
+3 -3
View File
@@ -1,8 +1,8 @@
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import type { Relay } from "@/lib/hooks"
import { slugify } from "@/lib/slugify"
import { validateSubdomainLabel } from "@/lib/subdomain"
import { setToastMessage } from "@/components/Toast"
import { RELAY_DOMAIN, validateSubdomainLabel } from "@/lib/subdomain"
import { setToastMessage } from "@/lib/state"
import { plans } from "@/lib/state"
export type RelayFormValues = Pick<Relay, "info_name" | "subdomain" | "info_icon" | "info_description" | "plan_id">
@@ -88,7 +88,7 @@ export default function RelayForm(props: RelayFormProps) {
required
class="flex-1 px-3 py-2"
/>
<span class="px-3 py-2 bg-gray-50 text-gray-500 text-sm border-l border-gray-300">.spaces.coracle.social</span>
<span class="px-3 py-2 bg-gray-50 text-gray-500 text-sm border-l border-gray-300">.{RELAY_DOMAIN}</span>
</div>
</div>
<div>
+12 -27
View File
@@ -1,8 +1,10 @@
import { A } from "@solidjs/router"
import { createEffect, createSignal, onCleanup, Show } from "solid-js"
import { Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import type { Relay } from "@/lib/api"
import { eventStore } from "@/lib/state"
import { useProfileMetadata } from "@/lib/hooks"
import { shortenPubkey } from "@/lib/pubkey"
import { RELAY_DOMAIN } from "@/lib/subdomain"
type RelayListItemProps = {
relay: Relay
@@ -10,28 +12,11 @@ type RelayListItemProps = {
showTenant?: boolean
}
function shortenPubkey(pubkey: string) {
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
export default function RelayListItem(props: RelayListItemProps) {
const [tenantProfile, setTenantProfile] = createSignal<{ name?: string; picture?: string }>({})
// Resolve the owning tenant's profile from the event store. The list that
// passes `showTenant` is responsible for priming these profiles in one batch.
createEffect(() => {
if (!props.showTenant) return
const pubkey = props.relay.tenant_pubkey
if (!pubkey) return
const sub = eventStore.profile(pubkey).subscribe((metadata) => {
setTenantProfile({
name: metadata?.name || metadata?.display_name,
picture: getProfilePicture(metadata),
})
})
onCleanup(() => sub.unsubscribe())
})
// 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.relay.tenant_pubkey : undefined), { prime: false })
return (
<li>
@@ -39,20 +24,20 @@ export default function RelayListItem(props: RelayListItemProps) {
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="font-medium text-gray-900">{props.relay.info_name || props.relay.subdomain}</p>
<p class="text-xs text-gray-500">{props.relay.subdomain}.spaces.coracle.social</p>
<p class="text-xs text-gray-500">{props.relay.subdomain}.{RELAY_DOMAIN}</p>
<Show when={props.showTenant}>
<div class="mt-1.5 flex items-center gap-2">
<Show
when={tenantProfile().picture}
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">
{(tenantProfile().name || props.relay.tenant_pubkey).slice(0, 1).toUpperCase()}
{((metadata()?.name || metadata()?.display_name) || props.relay.tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={tenantProfile().picture} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
<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">{tenantProfile().name || shortenPubkey(props.relay.tenant_pubkey)}</span>
<span class="text-xs text-gray-500 truncate">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.relay.tenant_pubkey)}</span>
</div>
</Show>
</div>
+1 -10
View File
@@ -1,14 +1,5 @@
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
type ToastVariant = "error" | "success"
const [toastVariant, setToastVariant] = createSignal<ToastVariant>("error")
export const [toastMessage, setRawToastMessage] = createSignal("")
export function setToastMessage(message: string, variant: ToastVariant = "error") {
setToastVariant(variant)
setRawToastMessage(message)
}
import { toastMessage, toastVariant, setToastMessage } from "@/lib/state"
export default function Toast() {
const [visible, setVisible] = createSignal(false)
@@ -0,0 +1,64 @@
import { Show } from "solid-js"
import { formatUsd } from "@/lib/format"
// Presentational invoice/draft row for the Payment History list. Status label,
// style, and period label are computed by the parent (Account.tsx) and passed in;
// PDF/pay actions are surfaced as callbacks. Props are reactive only when read
// lazily, so access props.* inside JSX, never destructure at the top.
type InvoiceListItemProps = {
amount: number
statusLabel: string
statusStyle: string
periodLabel: string
method?: string
isDraft?: boolean
isOpen?: boolean
onPay?: () => void
onPrintPdf: () => void
printing: boolean
}
export default function InvoiceListItem(props: InvoiceListItemProps) {
return (
<li class={`rounded-lg border p-4 text-sm ${props.isDraft ? "border-dashed border-gray-300" : "border-gray-200"}`}>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{formatUsd(props.amount)}</span>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${props.statusStyle}`}>
{props.statusLabel}
</span>
<Show when={props.method}>
<span class="text-xs text-gray-500">· paid via {props.method}</span>
</Show>
</div>
<Show when={props.periodLabel}>
<p class="text-xs text-gray-500 mt-0.5">{props.periodLabel}</p>
</Show>
<Show when={props.isDraft}>
<p class="text-xs text-gray-400 mt-1">Charges accruing this period. You'll be invoiced once a balance is due.</p>
</Show>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<Show when={props.isOpen}>
<button
type="button"
onClick={() => props.onPay?.()}
class="py-1 px-3 bg-blue-600 text-white text-xs font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Pay now
</button>
</Show>
<button
type="button"
onClick={props.onPrintPdf}
disabled={props.printing}
class="text-xs font-medium text-gray-500 hover:text-gray-800 underline disabled:opacity-50"
>
PDF
</button>
</div>
</div>
</li>
)
}
@@ -0,0 +1,53 @@
import { Show } from "solid-js"
import type { PaymentMethodState } from "@/lib/paymentMethod"
// Style/label lookups for a payment method's state, co-located with the row that
// consumes them so any future remodel of PaymentMethodState is a single-file
// change.
const methodStatusStyles: Record<PaymentMethodState["kind"], string> = {
not_set_up: "bg-gray-100 text-gray-500 border-gray-200",
ok: "bg-green-50 text-green-700 border-green-200",
error: "bg-red-50 text-red-700 border-red-200",
}
const methodStatusLabels: Record<PaymentMethodState["kind"], string> = {
not_set_up: "not set up",
ok: "ok",
error: "error",
}
// Presentational payment-method list row (title, optional error line, status
// badge, Set up/Update CTA). Props are reactive only when read lazily, so access
// props.* inside JSX, never destructure at the top.
type PaymentMethodRowProps = {
title: string
state: PaymentMethodState
onAction: () => void
}
export default function PaymentMethodRow(props: PaymentMethodRowProps) {
return (
<li class="rounded-lg border border-gray-200 p-4">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">{props.title}</p>
<Show when={props.state.kind === "error"}>
<p class="text-xs text-red-600 mt-0.5 break-words">{(props.state as { message: string }).message}</p>
</Show>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${methodStatusStyles[props.state.kind]}`}>
{methodStatusLabels[props.state.kind]}
</span>
<button
type="button"
onClick={props.onAction}
class="text-sm font-medium text-blue-600 hover:text-blue-700 disabled:opacity-50"
>
{props.state.kind === "not_set_up" ? "Set up" : "Update"}
</button>
</div>
</div>
</li>
)
}
@@ -0,0 +1,94 @@
import { Show } from "solid-js"
type KeyTab = "plaintext" | "encrypted"
// Presentational key-login panel. The actual login (loginWithKeyMaterial) stays
// in Login.tsx and is invoked via onSubmit; preventDefault is handled here so the
// parent only supplies the async login. Props are reactive only when read lazily,
// so access props.* inside JSX, never destructure signal-bearing props at the top.
type LoginKeyScreenProps = {
keyTab: () => KeyTab
setKeyTab: (tab: KeyTab) => void
nsecValue: () => string
setNsecValue: (value: string) => void
ncryptsecValue: () => string
setNcryptsecValue: (value: string) => void
password: () => string
setPassword: (value: string) => void
loading: () => boolean
onBack: () => void
onSubmit: () => void
}
export default function LoginKeyScreen(props: LoginKeyScreenProps) {
return (
<>
<button
type="button"
class="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
onClick={props.onBack}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Back
</button>
<h2 class="mt-3 text-lg font-semibold text-gray-900">Log in with key</h2>
<div class="mt-4 space-y-4">
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.keyTab() === "plaintext" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setKeyTab("plaintext")}
>
Plaintext
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.keyTab() === "encrypted" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setKeyTab("encrypted")}
>
Encrypted
</button>
</div>
<form
class="space-y-3"
onSubmit={(e) => {
e.preventDefault()
props.onSubmit()
}}
>
<Show when={props.keyTab() === "plaintext"}>
<input
value={props.nsecValue()}
onInput={(e) => props.setNsecValue(e.currentTarget.value)}
placeholder="nsec1..."
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</Show>
<Show when={props.keyTab() === "encrypted"}>
<input
value={props.ncryptsecValue()}
onInput={(e) => props.setNcryptsecValue(e.currentTarget.value)}
placeholder="ncryptsec1..."
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<input
type="password"
value={props.password()}
onInput={(e) => props.setPassword(e.currentTarget.value)}
placeholder="Password"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</Show>
<button
type="submit"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={props.loading()}
>
Log in
</button>
</form>
</div>
</>
)
}
@@ -0,0 +1,108 @@
import { Show } from "solid-js"
type SignerTab = "qr" | "paste"
// Presentational NIP-46 signer panel. No signers are created here; QR/URI are
// generated in Login.tsx and passed down, and actions are surfaced as callbacks.
// Props are reactive only when read lazily, so access props.* inside JSX, never
// destructure signal-bearing props at the top.
type LoginSignerScreenProps = {
signerTab: () => SignerTab
setSignerTab: (tab: SignerTab) => void
qrDataUrl: () => string
nostrConnectUri: () => string
bunkerUrl: () => string
setBunkerUrl: (value: string) => void
loading: () => boolean
onBack: () => void
onCopyUri: () => void
onScan: () => void
onConnectBunker: () => void
}
export default function LoginSignerScreen(props: LoginSignerScreenProps) {
return (
<>
<button
type="button"
class="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
onClick={props.onBack}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Back
</button>
<h2 class="mt-3 text-lg font-semibold text-gray-900">Log in with signer</h2>
<div class="mt-4 space-y-4">
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.signerTab() === "qr" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setSignerTab("qr")}
>
Use QR Code
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.signerTab() === "paste" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setSignerTab("paste")}
>
Paste Link
</button>
</div>
<Show when={props.signerTab() === "qr"}>
<Show when={props.qrDataUrl()} fallback={
<div class="flex items-center justify-center py-8 text-sm text-gray-400">
{props.loading() ? "Generating..." : "Loading QR code..."}
</div>
}>
<img src={props.qrDataUrl()} alt="Nostrconnect QR code" class="mx-auto rounded-lg" />
</Show>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={props.nostrConnectUri()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={props.onCopyUri}
title="Copy link"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</div>
</Show>
<Show when={props.signerTab() === "paste"}>
<div class="flex rounded-lg border border-gray-300">
<input
value={props.bunkerUrl()}
onInput={(e) => props.setBunkerUrl(e.currentTarget.value)}
placeholder="bunker://..."
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-sm bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={props.onScan}
title="Scan QR code"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
</button>
</div>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={props.loading() || !props.bunkerUrl().trim()}
onClick={props.onConnectBunker}
>
Connect to Signer
</button>
</Show>
</div>
</>
)
}
@@ -0,0 +1,84 @@
import { Show } from "solid-js"
import type { Tab } from "@/lib/loginInput"
// Presentational tab-selection panel for the login screen. All login logic stays
// in Login.tsx; this only renders tabs and surfaces continue callbacks. Props are
// reactive only when read lazily, so access props.* inside JSX, never destructure
// signal-bearing props at the top.
type LoginTabsScreenProps = {
tab: () => Tab
setTab: (tab: Tab) => void
loading: () => boolean
hasExtension: boolean
onContinueExtension: () => void
onContinueSigner: () => void
onContinueKey: () => void
}
export default function LoginTabsScreen(props: LoginTabsScreenProps) {
return (
<>
<h2 class="text-lg font-semibold text-gray-900">Log in / Sign up</h2>
<p class="mt-2 text-xs text-gray-500">
Use any Nostr signer method. New users are automatically onboarded.
</p>
<div class="mt-6 space-y-4">
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.tab() === "nip07" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setTab("nip07")}
disabled={!props.hasExtension}
>
Extension
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.tab() === "nip46" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setTab("nip46")}
>
Signer
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.tab() === "key" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setTab("key")}
>
Key
</button>
</div>
<Show when={props.tab() === "nip07"}>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={!props.hasExtension || props.loading()}
onClick={props.onContinueExtension}
>
{props.loading() ? "Connecting..." : <>Continue with extension <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg></>}
</button>
</Show>
<Show when={props.tab() === "nip46"}>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium"
onClick={props.onContinueSigner}
>
Continue with signer <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</Show>
<Show when={props.tab() === "key"}>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium"
onClick={props.onContinueKey}
>
Continue with key <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</Show>
</div>
</>
)
}
@@ -0,0 +1,29 @@
import { Show } from "solid-js"
// Presentational scanner overlay chrome. The <video> ref is owned by the parent
// via the videoRef setter so the QrScanner instance and its lifecycle stay in
// Login.tsx (openScanner waits a microtask for this element to mount). Props are
// reactive only when read lazily, so access props.* inside JSX.
type QrScannerOverlayProps = {
open: boolean
onClose: () => void
videoRef: (el: HTMLVideoElement) => void
}
export default function QrScannerOverlay(props: QrScannerOverlayProps) {
return (
<Show when={props.open}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={props.onClose}>
<div class="relative w-full max-w-sm rounded-2xl bg-white p-4 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900">Scan QR Code</h3>
<button type="button" class="text-gray-400 hover:text-gray-700" onClick={props.onClose}>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<video ref={props.videoRef} class="w-full rounded-lg" />
</div>
</div>
</Show>
)
}
@@ -0,0 +1,36 @@
import { For, Show, createMemo } from "solid-js"
import type { InvoiceItem } from "@/lib/api"
import { formatUsd } from "@/lib/format"
const MAX_VISIBLE_ITEMS = 8
// Presentational "On this invoice" line-items block. The createResource that
// fetches items stays in PaymentDialog; this only renders the parsed list. Props
// are reactive only when read lazily, so access props.* inside JSX/derivations.
type InvoiceItemsListProps = {
items: InvoiceItem[]
}
export default function InvoiceItemsList(props: InvoiceItemsListProps) {
const visibleItems = createMemo(() => props.items.slice(0, MAX_VISIBLE_ITEMS))
return (
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">On this invoice</p>
<ul class="space-y-1.5">
<For each={visibleItems()}>
{(item) => (
<li class="flex items-center justify-between gap-3 text-sm">
<span class="truncate text-gray-900">{item.description}</span>
<span class="flex-shrink-0 text-xs text-gray-500">{formatUsd(item.amount)}</span>
</li>
)}
</For>
<Show when={props.items.length > MAX_VISIBLE_ITEMS}>
<li class="text-xs text-gray-500">
+ {props.items.length - MAX_VISIBLE_ITEMS} more
</li>
</Show>
</ul>
</div>
)
}
@@ -0,0 +1,66 @@
import { Show } from "solid-js"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
// Presentational lightning payment body: loading/error/ready states, the bolt11
// QR + input, and copy. All fetching/QR generation stays in PaymentDialog and is
// surfaced via accessors/callbacks. Props are reactive only when read lazily, so
// access props.* inside JSX, never destructure signal-bearing props at the top.
type LightningPayBodyProps = {
bolt11Status: () => Bolt11Status
bolt11: () => string
qrDataUrl: () => string
bolt11Error: () => string
onRetry: () => void
onCopy: () => void
}
export default function LightningPayBody(props: LightningPayBodyProps) {
return (
<>
<Show when={props.bolt11Status() === "idle" || props.bolt11Status() === "loading"}>
<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>
</Show>
<Show when={props.bolt11Status() === "error"}>
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm font-medium text-red-700">Unable to generate invoice</p>
<p class="mt-1 text-xs text-red-600 wrap-break-word">{props.bolt11Error()}</p>
<button
type="button"
onClick={props.onRetry}
class="mt-3 inline-flex items-center rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
>
Retry
</button>
</div>
</Show>
<Show when={props.bolt11Status() === "ready"}>
<img src={props.qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
<Show when={props.bolt11()}>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={props.bolt11()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={props.onCopy}
title="Copy invoice"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button>
</div>
</Show>
<p class="text-xs text-gray-500 text-center">
Scan this QR code with a Bitcoin Lightning wallet to pay.
</p>
</Show>
</>
)
}
@@ -0,0 +1,48 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import ToggleButton from "@/components/ToggleButton"
// Pure presentational toggle that gates a feature behind plan limits. Props are
// reactive only when read lazily, so access props.* inside JSX/derivations and
// never destructure at the top.
type PlanGatedToggleProps = {
enabled: boolean
fallbackEnabled: boolean
planLimited: boolean
showPlanActions: boolean
canUpdatePlan: boolean
editHref?: string
onToggle?: () => void
}
export default function PlanGatedToggle(props: PlanGatedToggleProps) {
return (
<Show
when={!props.planLimited}
fallback={
<Show when={props.showPlanActions} fallback={<ToggleButton enabled={props.fallbackEnabled} onToggle={props.onToggle} />}>
<Show
when={props.canUpdatePlan}
fallback={
<A
href={props.editHref ?? "#"}
class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
>
Upgrade Plan
</A>
}
>
<span class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700">
Update Plan
</span>
</Show>
</Show>
}
>
<ToggleButton
enabled={props.enabled}
onToggle={props.onToggle}
/>
</Show>
)
}
@@ -0,0 +1,177 @@
import { A } from "@solidjs/router"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import { getProfilePicture, type ProfileContent } from "applesauce-core/helpers/profile"
import type { Relay } from "@/lib/api"
import menuDotsIcon from "@/assets/menu-dots-2.svg"
import { shortenPubkey } from "@/lib/pubkey"
import { RELAY_DOMAIN } from "@/lib/subdomain"
const STATUS_STYLES: Record<string, string> = {
active: "bg-green-50 text-green-700 border-green-200",
inactive: "bg-gray-100 text-gray-500 border-gray-200",
}
export function StatusBadge(props: { status: string }) {
const styles = () => STATUS_STYLES[props.status] ?? "bg-gray-100 text-gray-500 border-gray-200"
const label = () => props.status.replace(/_/g, " ")
return (
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${styles()}`}>
{label()}
</span>
)
}
// Presentational header for RelayDetailCard: icon/title/status, the WSS link, the
// optional tenant profile link, the sync_error banner, and the actions dropdown.
// The dropdown owns its own open/close UI state and document listeners; all relay
// mutations are surfaced as callbacks. Props are reactive only when read lazily,
// so access props.* (and accessor props like props.relay()) inside JSX, never
// destructure at the top.
type RelayCardHeaderProps = {
relay: () => Relay
showTenant?: boolean
tenantProfile: () => ProfileContent | undefined
editHref?: string
deactivating?: boolean
reactivating?: boolean
onRequestDeactivate?: () => void
onRequestReactivate?: () => void
}
export default function RelayCardHeader(props: RelayCardHeaderProps) {
const r = () => props.relay()
const metadata = () => props.tenantProfile()
const [menuOpen, setMenuOpen] = createSignal(false)
let menuContainerRef: HTMLDivElement | undefined
createEffect(() => {
if (!menuOpen()) return
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null
if (target && !menuContainerRef?.contains(target)) {
setMenuOpen(false)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMenuOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
document.addEventListener("keydown", handleEscape)
onCleanup(() => {
document.removeEventListener("mousedown", handleClickOutside)
document.removeEventListener("keydown", handleEscape)
})
})
return (
<>
{/* Header */}
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 min-w-0">
<Show when={r().info_icon}>
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover shrink-0 border border-gray-200" />
</Show>
<div class="min-w-0">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-gray-900">{r().info_name || r().subdomain}</h1>
<StatusBadge status={r().status} />
</div>
<a
href={`wss://${r().subdomain}.${RELAY_DOMAIN}`}
class="text-sm text-blue-600 hover:underline break-all"
>
wss://{r().subdomain}.{RELAY_DOMAIN}
</a>
<Show when={props.showTenant}>
<A href={`/admin/tenants/${r().tenant_pubkey}`} class="group mt-1.5 flex w-fit items-center gap-2 min-w-0">
<Show
when={getProfilePicture(metadata())}
fallback={
<div class="h-6 w-6 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{((metadata()?.name || metadata()?.display_name) || r().tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={getProfilePicture(metadata())} alt="" class="h-6 w-6 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="text-sm text-blue-600 group-hover:underline truncate">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(r().tenant_pubkey)}</span>
</A>
</Show>
<Show when={r().info_description.trim()}>
<p class="mt-2 text-sm text-gray-600">{r().info_description}</p>
</Show>
</div>
</div>
<Show when={props.editHref && (props.onRequestDeactivate || props.onRequestReactivate)}>
<div class="relative shrink-0" ref={menuContainerRef}>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50"
aria-label="Open relay actions"
onClick={() => setMenuOpen((open) => !open)}
>
<img src={menuDotsIcon} alt="" class="h-5 w-5" />
</button>
<div
class="absolute right-0 mt-2 w-44 rounded-lg border border-gray-200 bg-white shadow-lg z-10 py-1 origin-top-right transition-all duration-150 ease-out"
classList={{
"opacity-100 scale-100": menuOpen(),
"opacity-0 scale-95 pointer-events-none": !menuOpen(),
}}
>
<A
href={props.editHref!}
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => setMenuOpen(false)}
>
Edit Details
</A>
<Show when={r().status === "active" && props.onRequestDeactivate}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
onClick={() => {
setMenuOpen(false)
props.onRequestDeactivate?.()
}}
disabled={props.deactivating}
>
{props.deactivating ? "Deactivating..." : "Deactivate"}
</button>
</Show>
<Show when={r().status === "inactive" && props.onRequestReactivate}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50"
onClick={() => {
setMenuOpen(false)
props.onRequestReactivate?.()
}}
disabled={props.reactivating}
>
{props.reactivating ? "Reactivating..." : "Reactivate"}
</button>
</Show>
</div>
</div>
</Show>
</div>
<Show when={r().sync_error}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p class="text-sm font-semibold text-red-800">Provisioning error</p>
<p class="mt-1 text-sm text-red-700 font-mono break-all">{r().sync_error}</p>
</div>
</Show>
</>
)
}
+1
View File
@@ -22,6 +22,7 @@ declare global {
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_RELAY_DOMAIN: string
readonly VITE_PLATFORM_NAME?: string
}
+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>`
+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://"
}
+57 -152
View File
@@ -2,19 +2,17 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import PageContainer from "@/components/PageContainer"
import LoadingState from "@/components/LoadingState"
import PaymentDialog from "@/components/PaymentDialog"
import useMinLoading from "@/components/useMinLoading"
import useMinLoading from "@/lib/useMinLoading"
import { useInvoicePdf } from "@/lib/useInvoicePdf"
import PaymentSetupNWC from "@/components/PaymentSetupNWC"
import PaymentSetupCard from "@/components/PaymentSetupCard"
import { useBillingStatus, accountStatus, type AccountStatus } from "@/lib/billing"
import { invoiceStatus, listDraftInvoiceItems, type Invoice } from "@/lib/api"
import { cardState, methodLabel, nwcState, type PaymentMethodState } from "@/lib/paymentMethod"
import { account } from "@/lib/state"
const methodLabels: Record<string, string> = {
nwc: "Lightning",
stripe: "Card",
oob: "Lightning",
}
import { formatPeriod } from "@/lib/format"
import PaymentMethodRow from "@/components/account/PaymentMethodRow"
import InvoiceListItem from "@/components/account/InvoiceListItem"
const invoiceStatusStyles: Record<string, string> = {
draft: "bg-blue-50 text-blue-700 border-blue-200",
@@ -29,19 +27,18 @@ const accountStatusStyles: Record<AccountStatus, string> = {
delinquent: "bg-red-50 text-red-700 border-red-200",
}
type MethodState = { status: "not set up" | "ok" | "error"; error?: string }
const methodStatusStyles: Record<MethodState["status"], string> = {
"not set up": "bg-gray-100 text-gray-500 border-gray-200",
ok: "bg-green-50 text-green-700 border-green-200",
error: "bg-red-50 text-red-700 border-red-200",
}
const INVOICE_PAGE_SIZE = 10
export default function Account() {
const billing = useBillingStatus()
const [selectedInvoice, setSelectedInvoice] = createSignal<Invoice | undefined>()
const [nwcModalOpen, setNwcModalOpen] = createSignal(false)
const [cardModalOpen, setCardModalOpen] = createSignal(false)
const [showAllInvoices, setShowAllInvoices] = createSignal(false)
const visibleInvoices = createMemo(() => {
const all = billing.invoices()
return showAllInvoices() ? all : all.slice(0, INVOICE_PAGE_SIZE)
})
const invoicesLoading = useMinLoading(() => billing.loading())
const { printInvoice, printing } = useInvoicePdf()
@@ -63,19 +60,18 @@ export default function Account() {
)
// Per-method state, reported independently so a concurrent error on one method
// isn't masked by the other.
const nwcState = createMemo<MethodState>(() => {
// isn't masked by the other. Derived from the shared boundary helpers so the
// badge surface can't drift from the billing prompts.
const nwc = createMemo<PaymentMethodState>(() => {
const t = billing.tenant()
if (!t?.nwc_is_set) return { status: "not set up" }
if (t.nwc_error) return { status: "error", error: t.nwc_error }
return { status: "ok" }
if (!t) return { kind: "not_set_up" }
return nwcState(t)
})
const cardState = createMemo<MethodState>(() => {
const card = createMemo<PaymentMethodState>(() => {
const t = billing.tenant()
if (!t?.stripe_payment_method_id) return { status: "not set up" }
if (t.stripe_error) return { status: "error", error: t.stripe_error }
return { status: "ok" }
if (!t) return { kind: "not_set_up" }
return cardState(t)
})
function logout() {
@@ -109,52 +105,10 @@ export default function Account() {
<ul class="space-y-3">
{/* Lightning / NWC row — CTA opens the NWC modal */}
<li class="rounded-lg border border-gray-200 p-4">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">Lightning (NWC)</p>
<Show when={nwcState().status === "error" && nwcState().error}>
<p class="text-xs text-red-600 mt-0.5 break-words">{nwcState().error}</p>
</Show>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${methodStatusStyles[nwcState().status]}`}>
{nwcState().status}
</span>
<button
type="button"
onClick={() => setNwcModalOpen(true)}
class="text-sm font-medium text-blue-600 hover:text-blue-700 disabled:opacity-50"
>
{nwcState().status === "not set up" ? "Set up" : "Update"}
</button>
</div>
</div>
</li>
<PaymentMethodRow title="Lightning (NWC)" state={nwc()} onAction={() => setNwcModalOpen(true)} />
{/* Card / Stripe row — CTA opens the card modal (which redirects to the Stripe portal) */}
<li class="rounded-lg border border-gray-200 p-4">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">Card</p>
<Show when={cardState().status === "error" && cardState().error}>
<p class="text-xs text-red-600 mt-0.5 break-words">{cardState().error}</p>
</Show>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${methodStatusStyles[cardState().status]}`}>
{cardState().status}
</span>
<button
type="button"
onClick={() => setCardModalOpen(true)}
class="text-sm font-medium text-blue-600 hover:text-blue-700 disabled:opacity-50"
>
{cardState().status === "not set up" ? "Set up" : "Update"}
</button>
</div>
</div>
</li>
<PaymentMethodRow title="Card" state={card()} onAction={() => setCardModalOpen(true)} />
</ul>
</section>
@@ -168,96 +122,47 @@ export default function Account() {
<ul class="space-y-3">
{/* Draft: this period's in-progress charges, not yet a real invoice. */}
<Show when={billing.draftInvoice()}>
{(draft) => {
const periodLabel = () => {
const start = new Date(draft().period_start * 1000)
const end = new Date(draft().period_end * 1000)
return `${start.toLocaleDateString()} ${end.toLocaleDateString()}`
}
return (
<li class="rounded-lg border border-dashed border-gray-300 p-4 text-sm">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">${(draft().amount / 100).toFixed(2)}</span>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${invoiceStatusStyles.draft}`}>
draft
</span>
</div>
<Show when={draft().period_start && draft().period_end}>
<p class="text-xs text-gray-500 mt-0.5">{periodLabel()}</p>
</Show>
<p class="text-xs text-gray-400 mt-1">Charges accruing this period. You'll be invoiced once a balance is due.</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={() => void printInvoice(draft(), () => listDraftInvoiceItems(draft().tenant_pubkey))}
disabled={printing()}
class="text-xs font-medium text-gray-500 hover:text-gray-800 underline disabled:opacity-50"
>
PDF
</button>
</div>
</div>
</li>
)
}}
{(draft) => (
<InvoiceListItem
isDraft
amount={draft().amount}
statusLabel="draft"
statusStyle={invoiceStatusStyles.draft}
periodLabel={formatPeriod(draft().period_start, draft().period_end)}
onPrintPdf={() => void printInvoice(draft(), () => listDraftInvoiceItems(draft().tenant_pubkey))}
printing={printing()}
/>
)}
</Show>
<For each={billing.invoices()}>
<For each={visibleInvoices()}>
{(invoice) => {
const status = () => invoiceStatus(invoice)
const isOpen = () => status() === "open"
const statusStyle = () => invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"
const periodLabel = () => {
const start = new Date(invoice.period_start * 1000)
const end = new Date(invoice.period_end * 1000)
return `${start.toLocaleDateString()} ${end.toLocaleDateString()}`
}
return (
<li class="rounded-lg border border-gray-200 p-4 text-sm">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">
${(invoice.amount / 100).toFixed(2)}
</span>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${statusStyle()}`}>
{status()}
</span>
<Show when={invoice.method}>
<span class="text-xs text-gray-500">· paid via {methodLabels[invoice.method!] ?? invoice.method}</span>
</Show>
</div>
<Show when={invoice.period_start && invoice.period_end}>
<p class="text-xs text-gray-500 mt-0.5">{periodLabel()}</p>
</Show>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<Show when={isOpen()}>
<button
type="button"
onClick={() => setSelectedInvoice(invoice)}
class="py-1 px-3 bg-blue-600 text-white text-xs font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Pay now
</button>
</Show>
<button
type="button"
onClick={() => void printInvoice(invoice)}
disabled={printing()}
class="text-xs font-medium text-gray-500 hover:text-gray-800 underline disabled:opacity-50"
>
PDF
</button>
</div>
</div>
</li>
<InvoiceListItem
amount={invoice.amount}
statusLabel={status()}
statusStyle={invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"}
periodLabel={formatPeriod(invoice.period_start, invoice.period_end)}
method={invoice.method ? methodLabel(invoice.method) : undefined}
isOpen={status() === "open"}
onPay={() => setSelectedInvoice(invoice)}
onPrintPdf={() => void printInvoice(invoice)}
printing={printing()}
/>
)
}}
</For>
<Show when={billing.invoices().length > INVOICE_PAGE_SIZE}>
<li>
<button
type="button"
onClick={() => setShowAllInvoices(v => !v)}
class="text-sm font-medium text-blue-600 hover:text-blue-700"
>
{showAllInvoices() ? "Show less" : `Show all (${billing.invoices().length})`}
</button>
</li>
</Show>
</ul>
</Show>
</Show>
@@ -279,7 +184,7 @@ export default function Account() {
<PaymentSetupNWC
open={nwcModalOpen()}
isUpdate={nwcState().status !== "not set up"}
isUpdate={nwc().kind !== "not_set_up"}
onClose={() => {
setNwcModalOpen(false)
billing.refetch()
@@ -288,7 +193,7 @@ export default function Account() {
<PaymentSetupCard
open={cardModalOpen()}
isUpdate={cardState().status !== "not set up"}
isUpdate={card().kind !== "not_set_up"}
onClose={() => {
setCardModalOpen(false)
billing.refetch()
+2 -40
View File
@@ -96,8 +96,8 @@ export default function Home() {
</h1>
<p class="text-xl text-gray-500 mb-10 max-w-2xl mx-auto leading-relaxed">
Spin up a private, managed Nostr relay for your community in minutes.
Full control over membership, access, and policies no DevOps required.
Spin up a private, managed Nostr relay for your community in minutes,
with full control over membership, access, and policies.
</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
@@ -255,44 +255,6 @@ export default function Home() {
</div>
</a>
{/* Chachi */}
<a
href="https://chachi.chat"
target="_blank"
rel="noopener noreferrer"
class="group flex flex-col gap-5 rounded-2xl border border-gray-200 bg-white p-8 hover:border-purple-300 hover:shadow-md transition-all"
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<img src={ChachiLogo} alt="Chachi" class="w-12 h-12 rounded-2xl shadow-md shadow-purple-200" />
<div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-purple-600 transition-colors">Chachi</h3>
<p class="text-xs text-gray-400">chachi.chat</p>
</div>
</div>
<span class="text-gray-300 group-hover:text-purple-400 transition-colors mt-1">
<ExternalLinkIcon />
</span>
</div>
<p class="text-sm text-gray-600 leading-relaxed">
A group chat app built on top of Nostr. Chachi makes it easy for your community
to have real-time conversations, all flowing through your own relay.
</p>
<div class="space-y-2">
{["Real-time group messaging", "Bring your own relay", "No accounts — just your Nostr key"].map(f => (
<div class="flex items-start gap-2 text-sm text-gray-600">
<CheckIcon />
{f}
</div>
))}
</div>
<div class="mt-auto pt-2">
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-purple-600">
Visit chachi.chat <ExternalLinkIcon />
</span>
</div>
</a>
{/* Nostrord */}
<a
href="https://nostrord.com/"
@@ -4,7 +4,7 @@ import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import useMinLoading from "@/lib/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed"
import { listRelayMembers } from "@/lib/api"
import { useRelay, useRelayActivity } from "@/lib/hooks"
+3 -3
View File
@@ -1,10 +1,10 @@
import Fuse from "fuse.js"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import { fuzzySearch } from "@/lib/search"
import PageContainer from "@/components/PageContainer"
import RelayListItem from "@/components/RelayListItem"
import ResourceState from "@/components/ResourceState"
import SearchInput from "@/components/SearchInput"
import useMinLoading from "@/components/useMinLoading"
import useMinLoading from "@/lib/useMinLoading"
import { primeProfiles, useAdminRelays } from "@/lib/hooks"
export default function AdminRelayList() {
@@ -25,7 +25,7 @@ export default function AdminRelayList() {
const list = relays() ?? []
const q = query().trim()
if (!q) return list
return new Fuse(list, { keys: ["info_name", "subdomain", "tenant_pubkey"], threshold: 0.35, ignoreLocation: true }).search(q).map(r => r.item)
return fuzzySearch(list, ["info_name", "subdomain", "tenant_pubkey"], q)
})
return (
+9 -34
View File
@@ -1,17 +1,13 @@
import { useParams } from "@solidjs/router"
import { createEffect, createSignal, For, onCleanup, Show } from "solid-js"
import { For, Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import RelayListItem from "@/components/RelayListItem"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import { primeProfiles, useAdminTenant, useAdminTenantRelays } from "@/lib/hooks"
import { eventStore } from "@/lib/state"
function shortenPubkey(pubkey: string) {
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
import useMinLoading from "@/lib/useMinLoading"
import { useAdminTenant, useAdminTenantRelays, useProfileMetadata } from "@/lib/hooks"
import { shortenPubkey } from "@/lib/pubkey"
export default function AdminTenantDetail() {
const params = useParams()
@@ -19,28 +15,7 @@ export default function AdminTenantDetail() {
const [tenant] = useAdminTenant(tenantId)
const [relays] = useAdminTenantRelays(tenantId)
const loading = useMinLoading(() => tenant.loading || relays.loading)
const [profile, setProfile] = createSignal<{ name?: string; picture?: string }>({})
createEffect(() => {
const pubkey = tenantId()
if (!pubkey) {
setProfile({})
return
}
const profileSub = eventStore.profile(pubkey).subscribe((metadata) => {
setProfile({
name: metadata?.name || metadata?.display_name,
picture: getProfilePicture(metadata),
})
})
const primeSub = primeProfiles([pubkey])
onCleanup(() => {
profileSub.unsubscribe()
primeSub.unsubscribe()
})
})
const metadata = useProfileMetadata(tenantId)
const churnedLabel = () => {
const ts = tenant()?.churned_at
@@ -53,17 +28,17 @@ export default function AdminTenantDetail() {
<BackLink href="/admin/tenants" label="Tenants" />
<div class="flex items-center gap-4 mb-6 py-2">
<Show
when={profile().picture}
when={getProfilePicture(metadata())}
fallback={
<div class="h-14 w-14 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-lg">
{(profile().name || tenantId()).slice(0, 1).toUpperCase()}
{((metadata()?.name || metadata()?.display_name) || tenantId()).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={profile().picture} alt="Profile" class="h-14 w-14 flex-shrink-0 rounded-full object-cover" />
<img src={getProfilePicture(metadata())} alt="Profile" class="h-14 w-14 flex-shrink-0 rounded-full object-cover" />
</Show>
<div class="min-w-0">
<h1 class="text-2xl font-bold text-gray-900 truncate">{profile().name || shortenPubkey(tenantId())}</h1>
<h1 class="text-2xl font-bold text-gray-900 truncate">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(tenantId())}</h1>
<p class="text-xs text-gray-500 break-all">{tenantId()}</p>
</div>
</div>
+18 -42
View File
@@ -1,58 +1,33 @@
import { A } from "@solidjs/router"
import Fuse from "fuse.js"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import { createMemo, createSignal, For, Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import PageContainer from "@/components/PageContainer"
import ResourceState from "@/components/ResourceState"
import SearchInput from "@/components/SearchInput"
import useMinLoading from "@/components/useMinLoading"
import { primeProfiles, useAdminTenants } from "@/lib/hooks"
import { eventStore } from "@/lib/state"
function shortenPubkey(pubkey: string) {
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
import useMinLoading from "@/lib/useMinLoading"
import { useAdminTenants, useProfileMetadataMap } from "@/lib/hooks"
import { shortenPubkey } from "@/lib/pubkey"
import { fuzzySearch } from "@/lib/search"
export default function AdminTenantList() {
const [query, setQuery] = createSignal("")
const [tenants] = useAdminTenants()
const [profiles, setProfiles] = createSignal<Record<string, { name?: string; about?: string; nip05?: string; picture?: string }>>({})
const profiles = useProfileMetadataMap(() => (tenants() ?? []).map((t) => t.pubkey))
const loading = useMinLoading(() => tenants.loading)
const filtered = createMemo(() => {
const list = (tenants() ?? []).map((tenant) => {
const profile = profiles()[tenant.pubkey]
return { ...tenant, profileName: profile?.name, profileAbout: profile?.about, profileNip05: profile?.nip05 }
return {
...tenant,
profileName: profile?.name || profile?.display_name,
profileAbout: profile?.about,
profileNip05: profile?.nip05,
}
})
const q = query().trim()
if (!q) return list
return new Fuse(list, { keys: ["pubkey", "status", "profileName", "profileAbout", "profileNip05"], threshold: 0.35, ignoreLocation: true }).search(q).map(r => r.item)
})
createEffect(() => {
const list = tenants() ?? []
if (!list.length) return
const pubkeys = list.map(t => t.pubkey)
const reqSub = primeProfiles(pubkeys)
const profileSubs = pubkeys.map((pubkey) =>
eventStore.profile(pubkey).subscribe((profile) => {
setProfiles(prev => ({
...prev,
[pubkey]: {
name: profile?.name || profile?.display_name,
about: profile?.about,
nip05: profile?.nip05,
picture: getProfilePicture(profile),
},
}))
}),
)
onCleanup(() => {
reqSub.unsubscribe()
for (const sub of profileSubs) sub.unsubscribe()
})
return fuzzySearch(list, ["pubkey", "status", "profileName", "profileAbout", "profileNip05"], q)
})
return (
@@ -68,19 +43,20 @@ export default function AdminTenantList() {
<For each={filtered()}>
{(tenant) => {
const profile = () => profiles()[tenant.pubkey]
const profileName = () => profile()?.name || profile()?.display_name
return (
<li>
<A href={`/admin/tenants/${tenant.pubkey}`} class="block border border-gray-200 rounded-lg p-4 bg-white hover:border-gray-300">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex items-start gap-3">
<Show
when={profile()?.picture}
fallback={<div class="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-xs">{(profile()?.name || tenant.pubkey).slice(0, 1).toUpperCase()}</div>}
when={getProfilePicture(profile())}
fallback={<div class="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-xs">{(profileName() || tenant.pubkey).slice(0, 1).toUpperCase()}</div>}
>
<img src={profile()?.picture} alt="Profile" class="h-10 w-10 rounded-full object-cover" />
<img src={getProfilePicture(profile())} alt="Profile" class="h-10 w-10 rounded-full object-cover" />
</Show>
<div class="min-w-0">
<p class="font-medium text-gray-900 truncate">{profile()?.name || shortenPubkey(tenant.pubkey)}</p>
<p class="font-medium text-gray-900 truncate">{profileName() || shortenPubkey(tenant.pubkey)}</p>
<p class="mt-1 text-sm text-gray-600 line-clamp-2">{profile()?.about || "No profile bio"}</p>
<p class="mt-1 text-xs text-gray-500 break-all">{tenant.pubkey}</p>
</div>
+2 -3
View File
@@ -6,13 +6,12 @@ import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import RelayDetailCard from "@/components/RelayDetailCard"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import useMinLoading from "@/lib/useMinLoading"
import ActivityFeed from "@/components/ActivityFeed"
import { listRelayMembers } from "@/lib/api"
import { useRelay, useRelayActivity } from "@/lib/hooks"
import useRelayToggles from "@/lib/useRelayToggles"
import { setBillingFlowActive } from "@/lib/billing"
import { refetchBilling } from "@/lib/state"
import { refetchBilling, setBillingFlowActive } from "@/lib/state"
export default function RelayDetail() {
const params = useParams()
+1 -1
View File
@@ -4,7 +4,7 @@ import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import useMinLoading from "@/lib/useMinLoading"
import { updateRelayById, useRelay } from "@/lib/hooks"
export default function RelayEdit(props: { basePath?: string; title?: string }) {
+3 -5
View File
@@ -1,12 +1,12 @@
import { A } from "@solidjs/router"
import Fuse from "fuse.js"
import { createMemo, createSignal, For, Show } from "solid-js"
import PageContainer from "@/components/PageContainer"
import RelayListItem from "@/components/RelayListItem"
import ResourceState from "@/components/ResourceState"
import SearchInput from "@/components/SearchInput"
import useMinLoading from "@/components/useMinLoading"
import useMinLoading from "@/lib/useMinLoading"
import { useTenantRelays } from "@/lib/hooks"
import { fuzzySearch } from "@/lib/search"
export default function RelayList() {
const [relays] = useTenantRelays()
@@ -17,9 +17,7 @@ export default function RelayList() {
const filtered = createMemo(() => {
const list = relays() ?? []
const q = query().trim()
const searched = q
? new Fuse(list, { keys: ["info_name", "subdomain"], threshold: 0.35, ignoreLocation: true }).search(q).map(r => r.item)
: list
const searched = fuzzySearch(list, ["info_name", "subdomain"], q)
return status() === "all" ? searched : searched.filter(r => r.status === status())
})
+15 -12
View File
@@ -7,8 +7,8 @@ import PaymentSetup from "@/components/PaymentSetup"
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
import { createRelayForActiveTenant, getLatestOpenInvoice, tenantNeedsPaymentSetup } from "@/lib/hooks"
import type { Invoice } from "@/lib/api"
import { setBillingFlowActive } from "@/lib/billing"
import { refetchBilling } from "@/lib/state"
import { decidePostPaidFlow } from "@/lib/relayPlanFlow"
import { refetchBilling, setBillingFlowActive } from "@/lib/state"
export default function RelayNew() {
const navigate = useNavigate()
@@ -38,18 +38,21 @@ 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()
if (needsSetup) {
// 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 invoice = await getLatestOpenInvoice()
if (invoice) {
setPendingInvoice(invoice)
const invoice = needsSetup ? await getLatestOpenInvoice() : null
const decision = decidePostPaidFlow({ needsSetup, invoice })
switch (decision.kind) {
case "pay_invoice":
setPendingInvoice(decision.invoice)
return
}
setPaymentSetupOpen(true)
return
case "setup":
setPaymentSetupOpen(true)
return
case "navigate":
break
}
}
+83 -284
View File
@@ -5,11 +5,17 @@ import { PasswordSigner } from "applesauce-signers"
import QrScanner from "qr-scanner"
import QRCode from "qrcode"
import { accountManager, ensureSessionTenant, identity, PLATFORM_NAME } from "@/lib/state"
import useMinLoading from "@/components/useMinLoading"
import { copyToClipboard } from "@/lib/clipboard"
import { validateBunkerUri } from "@/lib/validation"
import { decideKeyLogin, initialLoginTab, normalizeBunkerUrl, type Tab } from "@/lib/loginInput"
import useMinLoading from "@/lib/useMinLoading"
import LoginTabsScreen from "@/components/login/LoginTabsScreen"
import LoginSignerScreen from "@/components/login/LoginSignerScreen"
import LoginKeyScreen from "@/components/login/LoginKeyScreen"
import QrScannerOverlay from "@/components/login/QrScannerOverlay"
const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc']
type Tab = "nip07" | "nip46" | "key"
type Screen = "tabs" | "nip46" | "key"
type SignerTab = "qr" | "paste"
type KeyTab = "plaintext" | "encrypted"
@@ -20,24 +26,6 @@ type LoginProps = {
onAuthenticated?: () => void | Promise<void>
}
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
}
async function loadNostrConnectSigner() {
return import("applesauce-signers").then((m) => m.NostrConnectSigner)
}
@@ -46,7 +34,7 @@ type LoginPageProps = LoginProps & Partial<RouteSectionProps<unknown>>
export default function Login(props: LoginPageProps = {}) {
const navigate = useNavigate()
const [tab, setTab] = createSignal<Tab>(window.nostr ? "nip07" : "nip46")
const [tab, setTab] = createSignal<Tab>(initialLoginTab(Boolean(window.nostr)))
const [rawLoading, setRawLoading] = createSignal(false)
const loading = useMinLoading(() => rawLoading())
const [error, setError] = createSignal("")
@@ -78,23 +66,29 @@ export default function Login(props: LoginPageProps = {}) {
await props.onAuthenticated?.()
}
async function loginWithNip07() {
// Shared effect wrapper for the four login handlers: clear the error, hold the
// loading flag for the duration, and surface any thrown error with a per-handler
// fallback message. The signer construction / completeLogin lives in `fn`.
async function runLogin(fn: () => Promise<void>, fallbackMessage: string) {
setError("")
setRawLoading(true)
try {
await completeLogin(await ExtensionAccount.fromExtension())
await fn()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to login with extension")
setError(e instanceof Error ? e.message : fallbackMessage)
} finally {
setRawLoading(false)
}
}
async function startNostrConnect() {
setError("")
setRawLoading(true)
function loginWithNip07() {
return runLogin(async () => {
await completeLogin(await ExtensionAccount.fromExtension())
}, "Failed to login with extension")
}
try {
function startNostrConnect() {
return runLogin(async () => {
const NostrConnectSigner = await loadNostrConnectSigner()
const signer = new NostrConnectSigner({ relays: NIP46_RELAYS })
const uri = signer.getNostrConnectURI({
@@ -115,54 +109,43 @@ export default function Login(props: LoginPageProps = {}) {
} finally {
window.clearTimeout(timeout)
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to connect signer")
} finally {
setRawLoading(false)
}
}, "Failed to connect signer")
}
async function loginWithBunker() {
function loginWithBunker() {
setError("")
setRawLoading(true)
try {
const validationError = validateBunkerUri(bunkerUrl())
if (validationError) {
setError(validationError)
return
}
return runLogin(async () => {
const uri = normalizeBunkerUrl(bunkerUrl())
const NostrConnectSigner = await loadNostrConnectSigner()
const signer = await NostrConnectSigner.fromBunkerURI(uri)
const pubkey = await signer.getPublicKey()
const account = new NostrConnectAccount(pubkey, signer)
await completeLogin(account)
} catch (e) {
setError(e instanceof Error ? e.message : "Invalid bunker URL")
} finally {
setRawLoading(false)
}
}, "Invalid bunker URL")
}
async function loginWithKeyMaterial() {
setError("")
setRawLoading(true)
try {
if (ncryptsecValue().trim()) {
if (!password().trim()) {
throw new Error("Password is required for ncryptsec")
function loginWithKeyMaterial() {
return runLogin(async () => {
const plan = decideKeyLogin({ ncryptsec: ncryptsecValue(), nsec: nsecValue(), password: password() })
switch (plan.kind) {
case "error":
throw new Error(plan.message)
case "ncryptsec": {
const signer = await PasswordSigner.fromNcryptsec(plan.ncryptsec, plan.password)
const pubkey = await signer.getPublicKey()
await completeLogin(new PasswordAccount(pubkey, signer))
break
}
const signer = await PasswordSigner.fromNcryptsec(ncryptsecValue().trim(), password())
const pubkey = await signer.getPublicKey()
const account = new PasswordAccount(pubkey, signer)
await completeLogin(account)
return
case "nsec":
await completeLogin(PrivateKeyAccount.fromKey(plan.key))
break
}
const key = nsecValue().trim()
if (!key) throw new Error("Enter an nsec or ncryptsec key")
const account = PrivateKeyAccount.fromKey(key)
await completeLogin(account)
} catch (e) {
setError(e instanceof Error ? e.message : "Invalid key")
} finally {
setRawLoading(false)
}
}, "Invalid key")
}
function closeScanner() {
@@ -203,7 +186,7 @@ export default function Login(props: LoginPageProps = {}) {
}
function copyUri() {
void navigator.clipboard.writeText(nostrConnectUri())
void copyToClipboard(nostrConnectUri(), { successMessage: "Link copied" })
}
createEffect(() => {
@@ -261,219 +244,47 @@ export default function Login(props: LoginPageProps = {}) {
<div class="rounded-xl border border-gray-200 bg-white/80 p-6">
<Show when={screen() === "tabs"}>
<h2 class="text-lg font-semibold text-gray-900">Log in / Sign up</h2>
<p class="mt-2 text-xs text-gray-500">
Use any Nostr signer method. New users are automatically onboarded.
</p>
<div class="mt-6 space-y-4">
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${tab() === "nip07" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => setTab("nip07")}
disabled={!window.nostr}
>
Extension
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${tab() === "nip46" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => setTab("nip46")}
>
Signer
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${tab() === "key" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => setTab("key")}
>
Key
</button>
</div>
<Show when={tab() === "nip07"}>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={!window.nostr || loading()}
onClick={loginWithNip07}
>
{loading() ? "Connecting..." : <>Continue with extension <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg></>}
</button>
</Show>
<Show when={tab() === "nip46"}>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium"
onClick={enterSignerScreen}
>
Continue with signer <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</Show>
<Show when={tab() === "key"}>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium"
onClick={() => { setError(""); setScreen("key") }}
>
Continue with key <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</Show>
</div>
<LoginTabsScreen
tab={tab}
setTab={setTab}
loading={loading}
hasExtension={Boolean(window.nostr)}
onContinueExtension={loginWithNip07}
onContinueSigner={enterSignerScreen}
onContinueKey={() => { setError(""); setScreen("key") }}
/>
</Show>
<Show when={screen() === "nip46"}>
<button
type="button"
class="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
onClick={() => { setError(""); setScreen("tabs") }}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Back
</button>
<h2 class="mt-3 text-lg font-semibold text-gray-900">Log in with signer</h2>
<div class="mt-4 space-y-4">
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${signerTab() === "qr" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => setSignerTab("qr")}
>
Use QR Code
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${signerTab() === "paste" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => setSignerTab("paste")}
>
Paste Link
</button>
</div>
<Show when={signerTab() === "qr"}>
<Show when={qrDataUrl()} fallback={
<div class="flex items-center justify-center py-8 text-sm text-gray-400">
{loading() ? "Generating..." : "Loading QR code..."}
</div>
}>
<img src={qrDataUrl()} alt="Nostrconnect QR code" class="mx-auto rounded-lg" />
</Show>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={nostrConnectUri()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={copyUri}
title="Copy link"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</div>
</Show>
<Show when={signerTab() === "paste"}>
<div class="flex rounded-lg border border-gray-300">
<input
value={bunkerUrl()}
onInput={(e) => setBunkerUrl(e.currentTarget.value)}
placeholder="bunker://..."
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-sm bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={openScanner}
title="Scan QR code"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
</button>
</div>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={loading() || !bunkerUrl().trim()}
onClick={loginWithBunker}
>
Connect to Signer
</button>
</Show>
</div>
<LoginSignerScreen
signerTab={signerTab}
setSignerTab={setSignerTab}
qrDataUrl={qrDataUrl}
nostrConnectUri={nostrConnectUri}
bunkerUrl={bunkerUrl}
setBunkerUrl={setBunkerUrl}
loading={loading}
onBack={() => { setError(""); setScreen("tabs") }}
onCopyUri={copyUri}
onScan={openScanner}
onConnectBunker={loginWithBunker}
/>
</Show>
<Show when={screen() === "key"}>
<button
type="button"
class="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
onClick={() => { setError(""); setScreen("tabs") }}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Back
</button>
<h2 class="mt-3 text-lg font-semibold text-gray-900">Log in with key</h2>
<div class="mt-4 space-y-4">
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${keyTab() === "plaintext" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => setKeyTab("plaintext")}
>
Plaintext
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${keyTab() === "encrypted" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => setKeyTab("encrypted")}
>
Encrypted
</button>
</div>
<form
class="space-y-3"
onSubmit={(e) => {
e.preventDefault()
void loginWithKeyMaterial()
}}
>
<Show when={keyTab() === "plaintext"}>
<input
value={nsecValue()}
onInput={(e) => setNsecValue(e.currentTarget.value)}
placeholder="nsec1..."
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</Show>
<Show when={keyTab() === "encrypted"}>
<input
value={ncryptsecValue()}
onInput={(e) => setNcryptsecValue(e.currentTarget.value)}
placeholder="ncryptsec1..."
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<input
type="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
placeholder="Password"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</Show>
<button
type="submit"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={loading()}
>
Log in
</button>
</form>
</div>
<LoginKeyScreen
keyTab={keyTab}
setKeyTab={setKeyTab}
nsecValue={nsecValue}
setNsecValue={setNsecValue}
ncryptsecValue={ncryptsecValue}
setNcryptsecValue={setNcryptsecValue}
password={password}
setPassword={setPassword}
loading={loading}
onBack={() => { setError(""); setScreen("tabs") }}
onSubmit={() => void loginWithKeyMaterial()}
/>
</Show>
<Show when={error()}>
@@ -487,19 +298,7 @@ export default function Login(props: LoginPageProps = {}) {
</p>
</div>
<Show when={showScanner()}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={closeScanner}>
<div class="relative w-full max-w-sm rounded-2xl bg-white p-4 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900">Scan QR Code</h3>
<button type="button" class="text-gray-400 hover:text-gray-700" onClick={closeScanner}>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<video ref={scannerVideo} class="w-full rounded-lg" />
</div>
</div>
</Show>
<QrScannerOverlay open={showScanner()} onClose={closeScanner} videoRef={(el) => { scannerVideo = el }} />
</div>
)
}