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