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
+4
View File
@@ -33,6 +33,10 @@ Avoid passing `&mut` to functions. The performance improvement often comes at th
Don't be overly DRY. Deep call trees are harder to read; factoring functions into many tiny pieces means that function boundaries are defined less by the domain or the responsibility of a given piece of code than by coincidental similarity. New functions should be created when 1. they represent a different concern that is the responsibility of a different part of the codebase, 2. the contained logic is repeated 3+ times, or 3. the contents of the function are complex and naming them makes the logical flow of the code easier to follow.
## Typescript
Don't add re-export shims to preserve old import paths. When something moves, import it from its new canonical location at every call site and update all the imports. Each symbol has exactly one place it's exported from.
## Verification
Check justfile and frontend/package.json for common commands for linting/building.
+3
View File
@@ -1,5 +1,8 @@
# Backend API base URL
VITE_API_URL=http://127.0.0.1:2892
# Domain that relay subdomains live under (must match the backend's RELAY_DOMAIN)
VITE_RELAY_DOMAIN=spaces.coracle.social
# Platform display name shown in UI
VITE_PLATFORM_NAME=Caravel
+2
View File
@@ -10,8 +10,10 @@ RUN bun install
COPY . .
ARG VITE_API_URL
ARG VITE_RELAY_DOMAIN
ARG VITE_PLATFORM_NAME
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_RELAY_DOMAIN=$VITE_RELAY_DOMAIN
ENV VITE_PLATFORM_NAME=$VITE_PLATFORM_NAME
RUN bun run build
+1
View File
@@ -33,6 +33,7 @@ Environment variables (see `.env.template`):
| Variable | Description | Default |
|---|---|---|
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:2892` |
| `VITE_RELAY_DOMAIN` | Domain relay subdomains live under (match backend `RELAY_DOMAIN`) | `spaces.coracle.social` |
## Running
+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>
)
}