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