forked from coracle/caravel
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.
|
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
|
## Verification
|
||||||
|
|
||||||
Check justfile and frontend/package.json for common commands for linting/building.
|
Check justfile and frontend/package.json for common commands for linting/building.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Backend API base URL
|
# Backend API base URL
|
||||||
VITE_API_URL=http://127.0.0.1:2892
|
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
|
# Platform display name shown in UI
|
||||||
VITE_PLATFORM_NAME=Caravel
|
VITE_PLATFORM_NAME=Caravel
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ RUN bun install
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG VITE_API_URL
|
ARG VITE_API_URL
|
||||||
|
ARG VITE_RELAY_DOMAIN
|
||||||
ARG VITE_PLATFORM_NAME
|
ARG VITE_PLATFORM_NAME
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_RELAY_DOMAIN=$VITE_RELAY_DOMAIN
|
||||||
ENV VITE_PLATFORM_NAME=$VITE_PLATFORM_NAME
|
ENV VITE_PLATFORM_NAME=$VITE_PLATFORM_NAME
|
||||||
|
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Environment variables (see `.env.template`):
|
|||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:2892` |
|
| `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
|
## Running
|
||||||
|
|
||||||
|
|||||||
+17
-14
@@ -14,7 +14,8 @@ import AdminTenantDetail from "@/pages/admin/AdminTenantDetail"
|
|||||||
import AdminRelayList from "@/pages/admin/AdminRelayList"
|
import AdminRelayList from "@/pages/admin/AdminRelayList"
|
||||||
import AdminRelayDetail from "@/pages/admin/AdminRelayDetail"
|
import AdminRelayDetail from "@/pages/admin/AdminRelayDetail"
|
||||||
import AdminRelayEdit from "@/pages/admin/AdminRelayEdit"
|
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 }) {
|
function Layout(props: { children?: any }) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -58,18 +59,20 @@ export default function App() {
|
|||||||
const requireTenant = (Page: Component) => requireCondition(Page, () => Boolean(identity()))
|
const requireTenant = (Page: Component) => requireCondition(Page, () => Boolean(identity()))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router root={Layout}>
|
<NostrProvider value={{ account, eventStore, pool }}>
|
||||||
<Route path="/" component={Home} />
|
<Router root={Layout}>
|
||||||
<Route path="/relays" component={requireTenant(RelayList)} />
|
<Route path="/" component={Home} />
|
||||||
<Route path="/relays/new" component={requireTenant(RelayNew)} />
|
<Route path="/relays" component={requireTenant(RelayList)} />
|
||||||
<Route path="/relays/:id" component={requireTenant(RelayDetail)} />
|
<Route path="/relays/new" component={requireTenant(RelayNew)} />
|
||||||
<Route path="/relays/:id/edit" component={requireTenant(RelayEdit)} />
|
<Route path="/relays/:id" component={requireTenant(RelayDetail)} />
|
||||||
<Route path="/account" component={requireTenant(Account)} />
|
<Route path="/relays/:id/edit" component={requireTenant(RelayEdit)} />
|
||||||
<Route path="/admin/tenants" component={requireAdmin(AdminTenantList)} />
|
<Route path="/account" component={requireTenant(Account)} />
|
||||||
<Route path="/admin/tenants/:id" component={requireAdmin(AdminTenantDetail)} />
|
<Route path="/admin/tenants" component={requireAdmin(AdminTenantList)} />
|
||||||
<Route path="/admin/relays" component={requireAdmin(AdminRelayList)} />
|
<Route path="/admin/tenants/:id" component={requireAdmin(AdminTenantDetail)} />
|
||||||
<Route path="/admin/relays/:id" component={requireAdmin(AdminRelayDetail)} />
|
<Route path="/admin/relays" component={requireAdmin(AdminRelayList)} />
|
||||||
<Route path="/admin/relays/:id/edit" component={requireAdmin(AdminRelayEdit)} />
|
<Route path="/admin/relays/:id" component={requireAdmin(AdminRelayDetail)} />
|
||||||
</Router>
|
<Route path="/admin/relays/:id/edit" component={requireAdmin(AdminRelayEdit)} />
|
||||||
|
</Router>
|
||||||
|
</NostrProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import { A, useLocation } from "@solidjs/router"
|
import { A, useLocation } from "@solidjs/router"
|
||||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||||
import Fuse from "fuse.js"
|
import { useProfileMetadata, useProfilePicture, useTenantRelays, type Relay } from "@/lib/hooks"
|
||||||
import { primeProfiles, useProfilePicture, useTenantRelays, type Relay } from "@/lib/hooks"
|
import { account, identity } from "@/lib/state"
|
||||||
import { account, eventStore, identity } from "@/lib/state"
|
import { fuzzySearch } from "@/lib/search"
|
||||||
|
import { RELAY_DOMAIN } from "@/lib/subdomain"
|
||||||
import serverIcon from "@/assets/server.svg"
|
import serverIcon from "@/assets/server.svg"
|
||||||
import Modal from "@/components/Modal"
|
import Modal from "@/components/Modal"
|
||||||
import BillingPrompts from "@/components/BillingPrompts"
|
import BillingPrompts from "@/components/BillingPrompts"
|
||||||
|
|
||||||
type Profile = {
|
|
||||||
name?: string
|
|
||||||
display_name?: string
|
|
||||||
nip05?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortenPubkey(pubkey?: string) {
|
function shortenPubkey(pubkey?: string) {
|
||||||
if (!pubkey) return ""
|
if (!pubkey) return ""
|
||||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-6)}`
|
return `${pubkey.slice(0, 8)}…${pubkey.slice(-6)}`
|
||||||
@@ -34,50 +29,18 @@ function RelayIcon() {
|
|||||||
export default function AppShell(props: { children?: any }) {
|
export default function AppShell(props: { children?: any }) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const picture = useProfilePicture(() => account()?.pubkey)
|
const picture = useProfilePicture(() => account()?.pubkey)
|
||||||
|
const metadata = useProfileMetadata(() => account()?.pubkey)
|
||||||
const [tenantRelays] = useTenantRelays()
|
const [tenantRelays] = useTenantRelays()
|
||||||
const [profile, setProfile] = createSignal<Profile>({})
|
|
||||||
const [searchOpen, setSearchOpen] = createSignal(false)
|
const [searchOpen, setSearchOpen] = createSignal(false)
|
||||||
const [searchQuery, setSearchQuery] = createSignal("")
|
const [searchQuery, setSearchQuery] = createSignal("")
|
||||||
|
|
||||||
const username = createMemo(() => profile().name || profile().display_name || shortenPubkey(account()?.pubkey))
|
const username = createMemo(() => metadata()?.name || metadata()?.display_name || shortenPubkey(account()?.pubkey))
|
||||||
const nip05 = createMemo(() => profile().nip05 || "No NIP-05")
|
const nip05 = createMemo(() => metadata()?.nip05)
|
||||||
const searchedRelays = createMemo<Relay[]>(() => {
|
const searchedRelays = createMemo<Relay[]>(() => {
|
||||||
const list = tenantRelays() ?? []
|
const list = tenantRelays() ?? []
|
||||||
const query = searchQuery().trim()
|
const query = searchQuery().trim()
|
||||||
|
|
||||||
if (!query) return list
|
return fuzzySearch(list, ["info_name", "subdomain"], query)
|
||||||
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const myResources = [{ href: "/relays", label: "My Relays" }]
|
const myResources = [{ href: "/relays", label: "My Relays" }]
|
||||||
@@ -224,7 +187,7 @@ export default function AppShell(props: { children?: any }) {
|
|||||||
onClick={closeSearchModal}
|
onClick={closeSearchModal}
|
||||||
>
|
>
|
||||||
<p class="text-sm font-medium text-gray-900">{relay.info_name || relay.subdomain}</p>
|
<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>
|
</A>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import PromptBanner, { type PromptBannerAction } from "@/components/PromptBanner
|
|||||||
import PaymentDialog from "@/components/PaymentDialog"
|
import PaymentDialog from "@/components/PaymentDialog"
|
||||||
import PaymentSetup from "@/components/PaymentSetup"
|
import PaymentSetup from "@/components/PaymentSetup"
|
||||||
import { getInvoice, type Invoice } from "@/lib/api"
|
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 = {
|
type BillingPromptsProps = {
|
||||||
// "banner" sits in the dashboard shell (mounted on every page except the
|
// "banner" sits in the dashboard shell (mounted on every page except the
|
||||||
@@ -76,7 +77,7 @@ export default function BillingPrompts(props: BillingPromptsProps) {
|
|||||||
|
|
||||||
function dismiss() {
|
function dismiss() {
|
||||||
const p = visiblePrompt()
|
const p = visiblePrompt()
|
||||||
if (p) setDismissed((prev) => new Set(prev).add(p.kind))
|
if (p) setDismissed((prev) => new Set([...prev, p.kind]))
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearDeepLink() {
|
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 QRCode from "qrcode"
|
||||||
import Modal from "@/components/Modal"
|
import Modal from "@/components/Modal"
|
||||||
import PaymentSetup from "@/components/PaymentSetup"
|
import PaymentSetup from "@/components/PaymentSetup"
|
||||||
import { CardSetupBody } from "@/components/PaymentSetupShell"
|
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 { useCardPortal } from "@/lib/usePaymentSetup"
|
||||||
import { getInvoice, getInvoiceBolt11, listInvoiceItems, type Invoice } from "@/lib/api"
|
import { getInvoice, getInvoiceBolt11, listInvoiceItems, type Invoice } from "@/lib/api"
|
||||||
|
import { autopayConfigured } from "@/lib/paymentMethod"
|
||||||
import { billingTenant } from "@/lib/state"
|
import { billingTenant } from "@/lib/state"
|
||||||
|
import { formatUsd, formatPeriod } from "@/lib/format"
|
||||||
|
|
||||||
type PayStatus = "idle" | "loading" | "success" | "error"
|
type PayStatus = "idle" | "loading" | "success" | "error"
|
||||||
type Bolt11Status = "idle" | "loading" | "ready" | "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.
|
// file we retry collection on this invoice automatically.
|
||||||
const card = useCardPortal()
|
const card = useCardPortal()
|
||||||
|
|
||||||
const autopayConfigured = () => {
|
const hasAutopay = () => {
|
||||||
const t = billingTenant()
|
const t = billingTenant()
|
||||||
return Boolean(t?.nwc_is_set || t?.stripe_payment_method_id)
|
return t ? autopayConfigured(t) : false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBolt11() {
|
async function loadBolt11() {
|
||||||
@@ -75,7 +80,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function copyBolt11() {
|
function copyBolt11() {
|
||||||
void navigator.clipboard.writeText(bolt11())
|
void copyToClipboard(bolt11(), { successMessage: "Invoice copied" })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkPayment() {
|
async function checkPayment() {
|
||||||
@@ -105,15 +110,9 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
|||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const amountLabel = () => `$${(props.invoice.amount / 100).toFixed(2)}`
|
const amountLabel = () => formatUsd(props.invoice.amount)
|
||||||
|
|
||||||
const periodLabel = () => {
|
const periodLabel = () => formatPeriod(props.invoice.period_start, props.invoice.period_end)
|
||||||
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}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -154,19 +153,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
|||||||
<div class="w-full space-y-4">
|
<div class="w-full space-y-4">
|
||||||
{/* What's being paid for — the invoice's actual line items */}
|
{/* What's being paid for — the invoice's actual line items */}
|
||||||
<Show when={(items() ?? []).length > 0}>
|
<Show when={(items() ?? []).length > 0}>
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
<InvoiceItemsList items={items() ?? []} />
|
||||||
<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>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Method switcher */}
|
{/* Method switcher */}
|
||||||
@@ -189,49 +176,14 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
|||||||
|
|
||||||
{/* Lightning: pay this invoice via a bolt11 QR */}
|
{/* Lightning: pay this invoice via a bolt11 QR */}
|
||||||
<Show when={payMethod() === "lightning"}>
|
<Show when={payMethod() === "lightning"}>
|
||||||
<Show when={bolt11Status() === "idle" || bolt11Status() === "loading"}>
|
<LightningPayBody
|
||||||
<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>
|
bolt11Status={bolt11Status}
|
||||||
</Show>
|
bolt11={bolt11}
|
||||||
<Show when={bolt11Status() === "error"}>
|
qrDataUrl={qrDataUrl}
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
|
bolt11Error={bolt11Error}
|
||||||
<p class="text-sm font-medium text-red-700">Unable to generate invoice</p>
|
onRetry={() => void loadBolt11()}
|
||||||
<p class="mt-1 text-xs text-red-600 wrap-break-word">{bolt11Error()}</p>
|
onCopy={copyBolt11}
|
||||||
<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>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Card: redirect to the Stripe billing portal */}
|
{/* Card: redirect to the Stripe billing portal */}
|
||||||
@@ -249,7 +201,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-sm font-medium text-gray-900">Payment confirmed!</p>
|
<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>
|
<p class="text-xs text-gray-500">Thank you. Your account is up to date.</p>
|
||||||
<Show when={!autopayConfigured()}>
|
<Show when={!hasAutopay()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPaymentSetup(true)}
|
onClick={() => setShowPaymentSetup(true)}
|
||||||
|
|||||||
@@ -1,35 +1,16 @@
|
|||||||
import { A } from "@solidjs/router"
|
import { Show, createSignal } from "solid-js"
|
||||||
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
|
||||||
import { getProfilePicture } from "applesauce-core/helpers/profile"
|
|
||||||
import type { Relay, PlanId } from "@/lib/api"
|
import type { Relay, PlanId } from "@/lib/api"
|
||||||
import menuDotsIcon from "@/assets/menu-dots-2.svg"
|
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog"
|
import ConfirmDialog from "@/components/ConfirmDialog"
|
||||||
import Field from "@/components/Field"
|
import Field from "@/components/Field"
|
||||||
import PricingTable from "@/components/PricingTable"
|
import PricingTable from "@/components/PricingTable"
|
||||||
import ToggleButton from "@/components/ToggleButton"
|
import ToggleButton from "@/components/ToggleButton"
|
||||||
import ToggleField from "@/components/ToggleField"
|
import ToggleField from "@/components/ToggleField"
|
||||||
import { setToastMessage } from "@/components/Toast"
|
import RelayCardHeader from "@/components/relay/RelayCardHeader"
|
||||||
import { primeProfiles } from "@/lib/hooks"
|
import PlanGatedToggle from "@/components/relay/PlanGatedToggle"
|
||||||
import { eventStore, plans } from "@/lib/state"
|
import { setToastMessage } from "@/lib/state"
|
||||||
|
import { useProfileMetadata } from "@/lib/hooks"
|
||||||
function shortenPubkey(pubkey: string) {
|
import { flagToBool } from "@/lib/relayFlags"
|
||||||
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
|
import { plans } from "@/lib/state"
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailSection(props: { title: string; children: any }) {
|
function DetailSection(props: { title: string; children: any }) {
|
||||||
return (
|
return (
|
||||||
@@ -76,38 +57,13 @@ type RelayDetailCardProps = {
|
|||||||
|
|
||||||
export default function RelayDetailCard(props: RelayDetailCardProps) {
|
export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||||
const r = () => props.relay
|
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 [planId, setPlanId] = createSignal<PlanId>(props.relay.plan_id)
|
||||||
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
|
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
|
// 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.
|
// avatar instead of a raw pubkey. Only relevant in admin (showTenant) views.
|
||||||
createEffect(() => {
|
// This subscription stays in the parent so the header doesn't double-subscribe.
|
||||||
if (!props.showTenant) return
|
const tenantProfile = useProfileMetadata(() => (props.showTenant ? props.relay.tenant_pubkey : undefined))
|
||||||
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
|
|
||||||
|
|
||||||
const memberLimitLabel = () => {
|
const memberLimitLabel = () => {
|
||||||
const p = plans().find(p => p.id === r().plan_id)
|
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") {
|
function openActionDialog(action: "deactivate" | "reactivate") {
|
||||||
setMenuOpen(false)
|
|
||||||
setPendingAction(action)
|
setPendingAction(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,144 +123,31 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
|||||||
setPendingAction(null)
|
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 (
|
return (
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{/* Header */}
|
<RelayCardHeader
|
||||||
<div class="flex items-start justify-between gap-4">
|
relay={r}
|
||||||
<div class="flex items-start gap-4 min-w-0">
|
showTenant={props.showTenant}
|
||||||
<Show when={r().info_icon}>
|
tenantProfile={tenantProfile}
|
||||||
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover shrink-0 border border-gray-200" />
|
editHref={props.editHref}
|
||||||
</Show>
|
deactivating={props.deactivating}
|
||||||
<div class="min-w-0">
|
reactivating={props.reactivating}
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
onRequestDeactivate={props.onDeactivate ? () => openActionDialog("deactivate") : undefined}
|
||||||
<h1 class="text-2xl font-bold text-gray-900">{r().info_name || r().subdomain}</h1>
|
onRequestReactivate={props.onReactivate ? () => openActionDialog("reactivate") : undefined}
|
||||||
<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>
|
|
||||||
|
|
||||||
<hr class="border-gray-200" />
|
<hr class="border-gray-200" />
|
||||||
|
|
||||||
<DetailSection title="Policy">
|
<DetailSection title="Policy">
|
||||||
<ToggleField label="Public join">
|
<ToggleField label="Public join">
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
enabled={flag(r().policy_public_join, false)}
|
enabled={flagToBool(r().policy_public_join, false)}
|
||||||
onToggle={props.onTogglePublicJoin}
|
onToggle={props.onTogglePublicJoin}
|
||||||
/>
|
/>
|
||||||
</ToggleField>
|
</ToggleField>
|
||||||
<ToggleField label="Strip signatures">
|
<ToggleField label="Strip signatures">
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
enabled={flag(r().policy_strip_signatures, false)}
|
enabled={flagToBool(r().policy_strip_signatures, false)}
|
||||||
onToggle={props.onToggleStripSignatures}
|
onToggle={props.onToggleStripSignatures}
|
||||||
/>
|
/>
|
||||||
</ToggleField>
|
</ToggleField>
|
||||||
@@ -316,79 +158,43 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
|||||||
<DetailSection title="Features">
|
<DetailSection title="Features">
|
||||||
<ToggleField label="Rooms">
|
<ToggleField label="Rooms">
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
enabled={flag(r().groups_enabled, true)}
|
enabled={flagToBool(r().groups_enabled, true)}
|
||||||
onToggle={props.onToggleGroups}
|
onToggle={props.onToggleGroups}
|
||||||
/>
|
/>
|
||||||
</ToggleField>
|
</ToggleField>
|
||||||
<ToggleField label="Management API">
|
<ToggleField label="Management API">
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
enabled={flag(r().management_enabled, true)}
|
enabled={flagToBool(r().management_enabled, true)}
|
||||||
onToggle={props.onToggleManagement}
|
onToggle={props.onToggleManagement}
|
||||||
/>
|
/>
|
||||||
</ToggleField>
|
</ToggleField>
|
||||||
<ToggleField label="Push notifications">
|
<ToggleField label="Push notifications">
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
enabled={flag(r().push_enabled, true)}
|
enabled={flagToBool(r().push_enabled, true)}
|
||||||
onToggle={props.onTogglePushNotifications}
|
onToggle={props.onTogglePushNotifications}
|
||||||
/>
|
/>
|
||||||
</ToggleField>
|
</ToggleField>
|
||||||
<ToggleField label="Media storage">
|
<ToggleField label="Media storage">
|
||||||
<Show
|
<PlanGatedToggle
|
||||||
when={!planLimited()}
|
enabled={flagToBool(r().blossom_enabled, true)}
|
||||||
fallback={
|
fallbackEnabled={flagToBool(r().blossom_enabled, false)}
|
||||||
<Show when={showPlanActions()} fallback={<ToggleButton enabled={flag(r().blossom_enabled, false)} onToggle={props.onToggleMediaStorage} />}>
|
planLimited={planLimited()}
|
||||||
<Show
|
showPlanActions={showPlanActions()}
|
||||||
when={props.onUpdatePlan}
|
canUpdatePlan={!!props.onUpdatePlan}
|
||||||
fallback={
|
editHref={props.editHref}
|
||||||
<A
|
onToggle={props.onToggleMediaStorage}
|
||||||
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>
|
|
||||||
</ToggleField>
|
</ToggleField>
|
||||||
<ToggleField label="LiveKit support">
|
<ToggleField label="LiveKit support">
|
||||||
<Show
|
<PlanGatedToggle
|
||||||
when={!planLimited()}
|
enabled={flagToBool(r().livekit_enabled, true)}
|
||||||
fallback={
|
fallbackEnabled={flagToBool(r().livekit_enabled, false)}
|
||||||
<Show when={showPlanActions()} fallback={<ToggleButton enabled={flag(r().livekit_enabled, false)} onToggle={props.onToggleLivekitSupport} />}>
|
planLimited={planLimited()}
|
||||||
<Show
|
showPlanActions={showPlanActions()}
|
||||||
when={props.onUpdatePlan}
|
canUpdatePlan={!!props.onUpdatePlan}
|
||||||
fallback={
|
editHref={props.editHref}
|
||||||
<A
|
onToggle={props.onToggleLivekitSupport}
|
||||||
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>
|
|
||||||
</ToggleField>
|
</ToggleField>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||||
import type { Relay } from "@/lib/hooks"
|
import type { Relay } from "@/lib/hooks"
|
||||||
import { slugify } from "@/lib/slugify"
|
import { slugify } from "@/lib/slugify"
|
||||||
import { validateSubdomainLabel } from "@/lib/subdomain"
|
import { RELAY_DOMAIN, validateSubdomainLabel } from "@/lib/subdomain"
|
||||||
import { setToastMessage } from "@/components/Toast"
|
import { setToastMessage } from "@/lib/state"
|
||||||
import { plans } from "@/lib/state"
|
import { plans } from "@/lib/state"
|
||||||
|
|
||||||
export type RelayFormValues = Pick<Relay, "info_name" | "subdomain" | "info_icon" | "info_description" | "plan_id">
|
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
|
required
|
||||||
class="flex-1 px-3 py-2"
|
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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { A } from "@solidjs/router"
|
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 { getProfilePicture } from "applesauce-core/helpers/profile"
|
||||||
import type { Relay } from "@/lib/api"
|
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 = {
|
type RelayListItemProps = {
|
||||||
relay: Relay
|
relay: Relay
|
||||||
@@ -10,28 +12,11 @@ type RelayListItemProps = {
|
|||||||
showTenant?: boolean
|
showTenant?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortenPubkey(pubkey: string) {
|
|
||||||
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RelayListItem(props: RelayListItemProps) {
|
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
|
// Resolve the owning tenant's profile from the event store. The list that
|
||||||
// passes `showTenant` is responsible for priming these profiles in one batch.
|
// passes `showTenant` is responsible for priming these profiles in one batch,
|
||||||
createEffect(() => {
|
// so this subscription does not prime on its own.
|
||||||
if (!props.showTenant) return
|
const metadata = useProfileMetadata(() => (props.showTenant ? props.relay.tenant_pubkey : undefined), { prime: false })
|
||||||
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())
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
@@ -39,20 +24,20 @@ export default function RelayListItem(props: RelayListItemProps) {
|
|||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="font-medium text-gray-900">{props.relay.info_name || props.relay.subdomain}</p>
|
<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}>
|
<Show when={props.showTenant}>
|
||||||
<div class="mt-1.5 flex items-center gap-2">
|
<div class="mt-1.5 flex items-center gap-2">
|
||||||
<Show
|
<Show
|
||||||
when={tenantProfile().picture}
|
when={getProfilePicture(metadata())}
|
||||||
fallback={
|
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">
|
<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>
|
</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>
|
</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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||||
|
import { toastMessage, toastVariant, setToastMessage } from "@/lib/state"
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Toast() {
|
export default function Toast() {
|
||||||
const [visible, setVisible] = createSignal(false)
|
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 {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_URL: string
|
readonly VITE_API_URL: string
|
||||||
|
readonly VITE_RELAY_DOMAIN: string
|
||||||
readonly VITE_PLATFORM_NAME?: string
|
readonly VITE_PLATFORM_NAME?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,12 @@ export type Tenant = {
|
|||||||
churned_at: number | null
|
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 = {
|
export type Invoice = {
|
||||||
id: string
|
id: string
|
||||||
tenant_pubkey: string
|
tenant_pubkey: string
|
||||||
@@ -114,7 +120,7 @@ export type Invoice = {
|
|||||||
created_at: number
|
created_at: number
|
||||||
paid_at: number | null
|
paid_at: number | null
|
||||||
voided_at: number | null
|
voided_at: number | null
|
||||||
method: "nwc" | "stripe" | "oob" | null
|
method: InvoiceMethod | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InvoiceItem = {
|
export type InvoiceItem = {
|
||||||
@@ -142,7 +148,7 @@ export type Bolt11 = {
|
|||||||
// The backend models an invoice's lifecycle as timestamps rather than a status
|
// 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
|
// field, so derive the display status from them: paid once paid_at is set, void
|
||||||
// once voided_at is set, otherwise still open.
|
// 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.paid_at != null) return "paid"
|
||||||
if (invoice.voided_at != null) return "void"
|
if (invoice.voided_at != null) return "void"
|
||||||
return "open"
|
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 { 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"
|
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 BillingPromptKind = "churned" | "pay_invoice" | "update_method" | "setup_autopay"
|
||||||
|
|
||||||
export type BillingPrompt = {
|
export type BillingPrompt = {
|
||||||
@@ -43,7 +40,7 @@ export function useBillingStatus() {
|
|||||||
const balance = () => openInvoices().reduce((sum, inv) => sum + inv.amount, 0)
|
const balance = () => openInvoices().reduce((sum, inv) => sum + inv.amount, 0)
|
||||||
|
|
||||||
const hasPaidSubscription = createMemo(() => {
|
const hasPaidSubscription = createMemo(() => {
|
||||||
const planById = new Map(plans().map((p) => [p.id, p]))
|
const planById = indexBy((p) => p.id, plans())
|
||||||
return (billingRelays() ?? []).some((relay) => {
|
return (billingRelays() ?? []).some((relay) => {
|
||||||
const plan = planById.get(relay.plan_id)
|
const plan = planById.get(relay.plan_id)
|
||||||
return Boolean(plan && plan.amount > 0 && relay.status === "active")
|
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 hasAutopay = autopayConfigured(tenant)
|
||||||
const methodError = tenant.nwc_error ?? tenant.stripe_error
|
const nwc = nwcState(tenant)
|
||||||
|
const card = cardState(tenant)
|
||||||
|
const methodError = nwc.kind === "error" || card.kind === "error"
|
||||||
const suppressInline = opts?.suppressInline ?? false
|
const suppressInline = opts?.suppressInline ?? false
|
||||||
|
|
||||||
// Any open invoice gets a "Pay now" surface, even with autopay configured:
|
// Any open invoice gets a "Pay now" surface, even with autopay configured:
|
||||||
@@ -96,13 +95,13 @@ export function activeBillingPrompt(
|
|||||||
return {
|
return {
|
||||||
kind: "update_method",
|
kind: "update_method",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
message: tenant.nwc_error
|
message: nwc.kind === "error"
|
||||||
? "Your Lightning wallet couldn't be charged. Update your payment method."
|
? "Your Lightning wallet couldn't be charged. Update your payment method."
|
||||||
: "Your card 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 {
|
return {
|
||||||
kind: "setup_autopay",
|
kind: "setup_autopay",
|
||||||
severity: "info",
|
severity: "info",
|
||||||
@@ -134,9 +133,9 @@ export function accountStatus(s: BillingStatusSnapshot): AccountStatus {
|
|||||||
if (!tenant) return "inactive"
|
if (!tenant) return "inactive"
|
||||||
if (tenant.churned_at) return "delinquent"
|
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)
|
const hasOpenInvoice = Boolean(s.openInvoice)
|
||||||
|
|
||||||
if (s.hasPaidSubscription || hasOpenInvoice || autopayConfigured) return "active"
|
if (s.hasPaidSubscription || hasOpenInvoice || hasAutopay) return "active"
|
||||||
return "inactive"
|
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 { 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 { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection"
|
||||||
import { includeMailboxes } from "applesauce-core/observable"
|
import { includeMailboxes } from "applesauce-core/observable"
|
||||||
import { map, of } from "rxjs"
|
import { map, of } from "rxjs"
|
||||||
@@ -24,56 +27,99 @@ import {
|
|||||||
type Tenant,
|
type Tenant,
|
||||||
type UpdateRelayInput,
|
type UpdateRelayInput,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
|
import { autopayConfigured } from "@/lib/paymentMethod"
|
||||||
import { account, eventStore, pool } from "@/lib/state"
|
import { account, eventStore, pool } from "@/lib/state"
|
||||||
|
import { useNostr } from "@/lib/nostr"
|
||||||
|
|
||||||
export function useProfilePicture(pubkey: () => string | undefined) {
|
// Subscribes to the raw ProfileContent for a single pubkey from the event store,
|
||||||
const [picture, setPicture] = createSignal<string | undefined>()
|
// 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(() => {
|
createEffect(() => {
|
||||||
const pk = pubkey()
|
const pk = pubkey()
|
||||||
|
|
||||||
if (!pk) {
|
if (!pk) {
|
||||||
setPicture(undefined)
|
setMetadata(undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileSub = eventStore.profile(pk).subscribe((profile) => {
|
const profileSub = nostr.eventStore.profile(pk).subscribe(setMetadata)
|
||||||
setPicture(getProfilePicture(profile))
|
const reqSub = prime ? primeProfiles([pk], nostr) : undefined
|
||||||
})
|
|
||||||
|
|
||||||
const reqSub = primeProfiles([pk])
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
profileSub.unsubscribe()
|
profileSub.unsubscribe()
|
||||||
reqSub.unsubscribe()
|
reqSub?.unsubscribe()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return picture
|
return metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
export function primeProfiles(pubkeys: string[]) {
|
// Batch variant of useProfileMetadata: subscribes to a list of pubkeys, priming
|
||||||
const uniquePubkeys = Array.from(new Set(pubkeys.filter(Boolean)))
|
// 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) {
|
if (uniquePubkeys.length === 0) {
|
||||||
return { unsubscribe() {} }
|
return { unsubscribe() {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
const seedRelays = Array.from(pool.relays.keys())
|
const seedRelays = Array.from(relayPool.relays.keys())
|
||||||
const mailboxSeedSub = seedRelays.length
|
const mailboxSeedSub = seedRelays.length
|
||||||
? pool.request(seedRelays, { kinds: [10002], authors: uniquePubkeys }).subscribe((event) => {
|
? relayPool.request(seedRelays, { kinds: [10002], authors: uniquePubkeys }).subscribe((event) => {
|
||||||
eventStore.add(event)
|
store.add(event)
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const outboxMap$ = of(uniquePubkeys.map((pubkey) => ({ pubkey }))).pipe(
|
const outboxMap$ = of(uniquePubkeys.map((pubkey) => ({ pubkey }))).pipe(
|
||||||
includeMailboxes(eventStore),
|
includeMailboxes(store),
|
||||||
map((pointers) => (seedRelays.length > 0 ? setFallbackRelays(pointers, seedRelays) : pointers)),
|
map((pointers) => (seedRelays.length > 0 ? setFallbackRelays(pointers, seedRelays) : pointers)),
|
||||||
map((pointers) => selectOptimalRelays(pointers, { maxConnections: 8, maxRelaysPerUser: 3 })),
|
map((pointers) => selectOptimalRelays(pointers, { maxConnections: 8, maxRelaysPerUser: 3 })),
|
||||||
map(createOutboxMap),
|
map(createOutboxMap),
|
||||||
)
|
)
|
||||||
|
|
||||||
const profileSub = pool.outboxSubscription(outboxMap$, { kinds: [0] }).subscribe((message) => {
|
const profileSub = relayPool.outboxSubscription(outboxMap$, { kinds: [0] }).subscribe((message) => {
|
||||||
if (message !== "EOSE") eventStore.add(message)
|
if (message !== "EOSE") store.add(message)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -136,7 +182,7 @@ export const reactivateRelayById = (id: string) => reactivateRelay(id)
|
|||||||
|
|
||||||
export async function tenantNeedsPaymentSetup(): Promise<boolean> {
|
export async function tenantNeedsPaymentSetup(): Promise<boolean> {
|
||||||
const tenant = await getTenant(account()!.pubkey)
|
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> {
|
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 { 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)
|
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 [plans] = createResource<Plan[]>(listPlans, { initialValue: [] })
|
||||||
|
|
||||||
export const [identity, { refetch: refetchIdentity, mutate: setIdentity }] = createResource(
|
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 const [billingDraftInvoice, { refetch: refetchBillingDraftInvoice }] = createResource(billingKey, getDraftInvoice)
|
||||||
|
|
||||||
export function refetchBilling() {
|
export function refetchBilling() {
|
||||||
void refetchBillingTenant()
|
void Promise.allSettled([
|
||||||
void refetchBillingInvoices()
|
refetchBillingTenant(),
|
||||||
void refetchBillingRelays()
|
refetchBillingInvoices(),
|
||||||
void refetchBillingDraftInvoice()
|
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
|
// Ensure the active pubkey's tenant row exists, then unlock billing reads. The
|
||||||
@@ -117,7 +140,13 @@ queueMicrotask(() => {
|
|||||||
accountManager.setActive(active)
|
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)
|
setAccount(account)
|
||||||
|
|
||||||
localStorage.setItem("caravel.accounts", JSON.stringify(accountManager.toJSON(true)))
|
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
|
// Lock billing reads until the new account's tenant is ensured, so they never
|
||||||
// fire against a not-yet-provisioned tenant during signup.
|
// fire against a not-yet-provisioned tenant during signup.
|
||||||
setBillingPubkey(undefined)
|
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 SUBDOMAIN_LABEL_MAX_LEN = 63
|
||||||
const RESERVED_SUBDOMAIN_LABELS = new Set(["api", "admin"])
|
const RESERVED_SUBDOMAIN_LABELS = new Set(["api", "admin"])
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import QRCode from "qrcode"
|
import QRCode from "qrcode"
|
||||||
import { getInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api"
|
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"
|
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()
|
const fmtDate = (seconds: number) => new Date(seconds * 1000).toLocaleDateString()
|
||||||
|
|
||||||
function escapeHtml(value: string) {
|
function escapeHtml(value: string) {
|
||||||
@@ -32,7 +27,10 @@ export function useInvoicePdf() {
|
|||||||
setPrinting(true)
|
setPrinting(true)
|
||||||
try {
|
try {
|
||||||
const fetchItems = loadItems ?? (() => listInvoiceItems(invoice.id))
|
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 sats: number | undefined
|
||||||
let qrDataUrl: string | undefined
|
let qrDataUrl: string | undefined
|
||||||
@@ -65,12 +63,12 @@ function buildHtml(opts: { invoice: Invoice; items: InvoiceItem[]; sats?: number
|
|||||||
|
|
||||||
const rows = items.length
|
const rows = items.length
|
||||||
? items
|
? 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("")
|
.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 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
|
const qr = qrDataUrl
|
||||||
? `<div class="qr"><img src="${qrDataUrl}" alt="Lightning invoice QR"/><div class="muted">Scan to pay by Lightning</div></div>`
|
? `<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>
|
<table>
|
||||||
<thead><tr><th>Description</th><th class="amt">Amount</th></tr></thead>
|
<thead><tr><th>Description</th><th class="amt">Amount</th></tr></thead>
|
||||||
<tbody>${rows}${satsRow}</tbody>
|
<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>
|
</table>
|
||||||
${qr}
|
${qr}
|
||||||
</body></html>`
|
</body></html>`
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { updateRelayById, deactivateRelayById, reactivateRelayById, getLatestOpenInvoice, tenantNeedsPaymentSetup, type Relay } from "@/lib/hooks"
|
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"
|
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 = {
|
type RelayResource = {
|
||||||
(): Relay | undefined
|
(): Relay | undefined
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -47,8 +38,7 @@ export default function useRelayToggles(
|
|||||||
function toggle(field: keyof Relay, fallback: boolean) {
|
function toggle(field: keyof Relay, fallback: boolean) {
|
||||||
const current = relay()
|
const current = relay()
|
||||||
if (!current) return
|
if (!current) return
|
||||||
const next = { ...current, [field]: toInt(!toBool(current[field] as number, fallback)) }
|
void updateRelay(toggleField(current, field, fallback), current)
|
||||||
void updateRelay(next, current)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeactivate() {
|
async function handleDeactivate() {
|
||||||
@@ -82,18 +72,10 @@ export default function useRelayToggles(
|
|||||||
if (!current) return
|
if (!current) return
|
||||||
|
|
||||||
const previous = current
|
const previous = current
|
||||||
const next = { ...current, plan_id }
|
mutate(applyPlanToRelay(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)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateRelayById(relayId(), update)
|
await updateRelayById(relayId(), planUpdatePayload(plan_id))
|
||||||
await refetch()
|
await refetch()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
mutate(previous)
|
mutate(previous)
|
||||||
@@ -101,18 +83,24 @@ export default function useRelayToggles(
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plan_id !== "free") {
|
if (plan_id === "free") return
|
||||||
const needsSetup = await tenantNeedsPaymentSetup()
|
|
||||||
if (needsSetup) {
|
// Materialize the invoice for this upgrade (no collection, no DM) so we can
|
||||||
// Materialize the invoice for this upgrade (no collection, no DM) so we
|
// prompt the tenant to pay it directly. listTenantInvoices reconciles first,
|
||||||
// can prompt the tenant to pay it directly. listTenantInvoices reconciles
|
// so a just-created invoice is visible here.
|
||||||
// first, so a just-created invoice is visible here.
|
const needsSetup = await tenantNeedsPaymentSetup()
|
||||||
const invoice = await getLatestOpenInvoice()
|
const invoice = needsSetup ? await getLatestOpenInvoice() : null
|
||||||
if (invoice) {
|
const decision = decidePostPaidFlow({ needsSetup, invoice })
|
||||||
setPendingInvoice(invoice)
|
switch (decision.kind) {
|
||||||
}
|
case "pay_invoice":
|
||||||
|
setPendingInvoice(decision.invoice)
|
||||||
setPendingPaymentSetup(true)
|
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 PageContainer from "@/components/PageContainer"
|
||||||
import LoadingState from "@/components/LoadingState"
|
import LoadingState from "@/components/LoadingState"
|
||||||
import PaymentDialog from "@/components/PaymentDialog"
|
import PaymentDialog from "@/components/PaymentDialog"
|
||||||
import useMinLoading from "@/components/useMinLoading"
|
import useMinLoading from "@/lib/useMinLoading"
|
||||||
import { useInvoicePdf } from "@/lib/useInvoicePdf"
|
import { useInvoicePdf } from "@/lib/useInvoicePdf"
|
||||||
import PaymentSetupNWC from "@/components/PaymentSetupNWC"
|
import PaymentSetupNWC from "@/components/PaymentSetupNWC"
|
||||||
import PaymentSetupCard from "@/components/PaymentSetupCard"
|
import PaymentSetupCard from "@/components/PaymentSetupCard"
|
||||||
import { useBillingStatus, accountStatus, type AccountStatus } from "@/lib/billing"
|
import { useBillingStatus, accountStatus, type AccountStatus } from "@/lib/billing"
|
||||||
import { invoiceStatus, listDraftInvoiceItems, type Invoice } from "@/lib/api"
|
import { invoiceStatus, listDraftInvoiceItems, type Invoice } from "@/lib/api"
|
||||||
|
import { cardState, methodLabel, nwcState, type PaymentMethodState } from "@/lib/paymentMethod"
|
||||||
import { account } from "@/lib/state"
|
import { account } from "@/lib/state"
|
||||||
|
import { formatPeriod } from "@/lib/format"
|
||||||
const methodLabels: Record<string, string> = {
|
import PaymentMethodRow from "@/components/account/PaymentMethodRow"
|
||||||
nwc: "Lightning",
|
import InvoiceListItem from "@/components/account/InvoiceListItem"
|
||||||
stripe: "Card",
|
|
||||||
oob: "Lightning",
|
|
||||||
}
|
|
||||||
|
|
||||||
const invoiceStatusStyles: Record<string, string> = {
|
const invoiceStatusStyles: Record<string, string> = {
|
||||||
draft: "bg-blue-50 text-blue-700 border-blue-200",
|
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",
|
delinquent: "bg-red-50 text-red-700 border-red-200",
|
||||||
}
|
}
|
||||||
|
|
||||||
type MethodState = { status: "not set up" | "ok" | "error"; error?: string }
|
const INVOICE_PAGE_SIZE = 10
|
||||||
|
|
||||||
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",
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
const billing = useBillingStatus()
|
const billing = useBillingStatus()
|
||||||
const [selectedInvoice, setSelectedInvoice] = createSignal<Invoice | undefined>()
|
const [selectedInvoice, setSelectedInvoice] = createSignal<Invoice | undefined>()
|
||||||
const [nwcModalOpen, setNwcModalOpen] = createSignal(false)
|
const [nwcModalOpen, setNwcModalOpen] = createSignal(false)
|
||||||
const [cardModalOpen, setCardModalOpen] = 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 invoicesLoading = useMinLoading(() => billing.loading())
|
||||||
const { printInvoice, printing } = useInvoicePdf()
|
const { printInvoice, printing } = useInvoicePdf()
|
||||||
|
|
||||||
@@ -63,19 +60,18 @@ export default function Account() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Per-method state, reported independently so a concurrent error on one method
|
// Per-method state, reported independently so a concurrent error on one method
|
||||||
// isn't masked by the other.
|
// isn't masked by the other. Derived from the shared boundary helpers so the
|
||||||
const nwcState = createMemo<MethodState>(() => {
|
// badge surface can't drift from the billing prompts.
|
||||||
|
const nwc = createMemo<PaymentMethodState>(() => {
|
||||||
const t = billing.tenant()
|
const t = billing.tenant()
|
||||||
if (!t?.nwc_is_set) return { status: "not set up" }
|
if (!t) return { kind: "not_set_up" }
|
||||||
if (t.nwc_error) return { status: "error", error: t.nwc_error }
|
return nwcState(t)
|
||||||
return { status: "ok" }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const cardState = createMemo<MethodState>(() => {
|
const card = createMemo<PaymentMethodState>(() => {
|
||||||
const t = billing.tenant()
|
const t = billing.tenant()
|
||||||
if (!t?.stripe_payment_method_id) return { status: "not set up" }
|
if (!t) return { kind: "not_set_up" }
|
||||||
if (t.stripe_error) return { status: "error", error: t.stripe_error }
|
return cardState(t)
|
||||||
return { status: "ok" }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
@@ -109,52 +105,10 @@ export default function Account() {
|
|||||||
|
|
||||||
<ul class="space-y-3">
|
<ul class="space-y-3">
|
||||||
{/* Lightning / NWC row — CTA opens the NWC modal */}
|
{/* Lightning / NWC row — CTA opens the NWC modal */}
|
||||||
<li class="rounded-lg border border-gray-200 p-4">
|
<PaymentMethodRow title="Lightning (NWC)" state={nwc()} onAction={() => setNwcModalOpen(true)} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Card / Stripe row — CTA opens the card modal (which redirects to the Stripe portal) */}
|
{/* Card / Stripe row — CTA opens the card modal (which redirects to the Stripe portal) */}
|
||||||
<li class="rounded-lg border border-gray-200 p-4">
|
<PaymentMethodRow title="Card" state={card()} onAction={() => setCardModalOpen(true)} />
|
||||||
<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>
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -168,96 +122,47 @@ export default function Account() {
|
|||||||
<ul class="space-y-3">
|
<ul class="space-y-3">
|
||||||
{/* Draft: this period's in-progress charges, not yet a real invoice. */}
|
{/* Draft: this period's in-progress charges, not yet a real invoice. */}
|
||||||
<Show when={billing.draftInvoice()}>
|
<Show when={billing.draftInvoice()}>
|
||||||
{(draft) => {
|
{(draft) => (
|
||||||
const periodLabel = () => {
|
<InvoiceListItem
|
||||||
const start = new Date(draft().period_start * 1000)
|
isDraft
|
||||||
const end = new Date(draft().period_end * 1000)
|
amount={draft().amount}
|
||||||
return `${start.toLocaleDateString()} – ${end.toLocaleDateString()}`
|
statusLabel="draft"
|
||||||
}
|
statusStyle={invoiceStatusStyles.draft}
|
||||||
return (
|
periodLabel={formatPeriod(draft().period_start, draft().period_end)}
|
||||||
<li class="rounded-lg border border-dashed border-gray-300 p-4 text-sm">
|
onPrintPdf={() => void printInvoice(draft(), () => listDraftInvoiceItems(draft().tenant_pubkey))}
|
||||||
<div class="flex items-center justify-between gap-3">
|
printing={printing()}
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Show>
|
</Show>
|
||||||
<For each={billing.invoices()}>
|
<For each={visibleInvoices()}>
|
||||||
{(invoice) => {
|
{(invoice) => {
|
||||||
const status = () => invoiceStatus(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 (
|
return (
|
||||||
<li class="rounded-lg border border-gray-200 p-4 text-sm">
|
<InvoiceListItem
|
||||||
<div class="flex items-center justify-between gap-3">
|
amount={invoice.amount}
|
||||||
<div class="min-w-0">
|
statusLabel={status()}
|
||||||
<div class="flex items-center gap-2">
|
statusStyle={invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"}
|
||||||
<span class="font-medium text-gray-900">
|
periodLabel={formatPeriod(invoice.period_start, invoice.period_end)}
|
||||||
${(invoice.amount / 100).toFixed(2)}
|
method={invoice.method ? methodLabel(invoice.method) : undefined}
|
||||||
</span>
|
isOpen={status() === "open"}
|
||||||
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${statusStyle()}`}>
|
onPay={() => setSelectedInvoice(invoice)}
|
||||||
{status()}
|
onPrintPdf={() => void printInvoice(invoice)}
|
||||||
</span>
|
printing={printing()}
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</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>
|
</ul>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -279,7 +184,7 @@ export default function Account() {
|
|||||||
|
|
||||||
<PaymentSetupNWC
|
<PaymentSetupNWC
|
||||||
open={nwcModalOpen()}
|
open={nwcModalOpen()}
|
||||||
isUpdate={nwcState().status !== "not set up"}
|
isUpdate={nwc().kind !== "not_set_up"}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setNwcModalOpen(false)
|
setNwcModalOpen(false)
|
||||||
billing.refetch()
|
billing.refetch()
|
||||||
@@ -288,7 +193,7 @@ export default function Account() {
|
|||||||
|
|
||||||
<PaymentSetupCard
|
<PaymentSetupCard
|
||||||
open={cardModalOpen()}
|
open={cardModalOpen()}
|
||||||
isUpdate={cardState().status !== "not set up"}
|
isUpdate={card().kind !== "not_set_up"}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setCardModalOpen(false)
|
setCardModalOpen(false)
|
||||||
billing.refetch()
|
billing.refetch()
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ export default function Home() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p class="text-xl text-gray-500 mb-10 max-w-2xl mx-auto leading-relaxed">
|
<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.
|
Spin up a private, managed Nostr relay for your community in minutes,
|
||||||
Full control over membership, access, and policies — no DevOps required.
|
with full control over membership, access, and policies.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
@@ -255,44 +255,6 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</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 */}
|
{/* Nostrord */}
|
||||||
<a
|
<a
|
||||||
href="https://nostrord.com/"
|
href="https://nostrord.com/"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import BackLink from "@/components/BackLink"
|
|||||||
import PageContainer from "@/components/PageContainer"
|
import PageContainer from "@/components/PageContainer"
|
||||||
import RelayDetailCard from "@/components/RelayDetailCard"
|
import RelayDetailCard from "@/components/RelayDetailCard"
|
||||||
import ResourceState from "@/components/ResourceState"
|
import ResourceState from "@/components/ResourceState"
|
||||||
import useMinLoading from "@/components/useMinLoading"
|
import useMinLoading from "@/lib/useMinLoading"
|
||||||
import ActivityFeed from "@/components/ActivityFeed"
|
import ActivityFeed from "@/components/ActivityFeed"
|
||||||
import { listRelayMembers } from "@/lib/api"
|
import { listRelayMembers } from "@/lib/api"
|
||||||
import { useRelay, useRelayActivity } from "@/lib/hooks"
|
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 { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
||||||
|
import { fuzzySearch } from "@/lib/search"
|
||||||
import PageContainer from "@/components/PageContainer"
|
import PageContainer from "@/components/PageContainer"
|
||||||
import RelayListItem from "@/components/RelayListItem"
|
import RelayListItem from "@/components/RelayListItem"
|
||||||
import ResourceState from "@/components/ResourceState"
|
import ResourceState from "@/components/ResourceState"
|
||||||
import SearchInput from "@/components/SearchInput"
|
import SearchInput from "@/components/SearchInput"
|
||||||
import useMinLoading from "@/components/useMinLoading"
|
import useMinLoading from "@/lib/useMinLoading"
|
||||||
import { primeProfiles, useAdminRelays } from "@/lib/hooks"
|
import { primeProfiles, useAdminRelays } from "@/lib/hooks"
|
||||||
|
|
||||||
export default function AdminRelayList() {
|
export default function AdminRelayList() {
|
||||||
@@ -25,7 +25,7 @@ export default function AdminRelayList() {
|
|||||||
const list = relays() ?? []
|
const list = relays() ?? []
|
||||||
const q = query().trim()
|
const q = query().trim()
|
||||||
if (!q) return list
|
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 (
|
return (
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { useParams } from "@solidjs/router"
|
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 { getProfilePicture } from "applesauce-core/helpers/profile"
|
||||||
import BackLink from "@/components/BackLink"
|
import BackLink from "@/components/BackLink"
|
||||||
import PageContainer from "@/components/PageContainer"
|
import PageContainer from "@/components/PageContainer"
|
||||||
import RelayListItem from "@/components/RelayListItem"
|
import RelayListItem from "@/components/RelayListItem"
|
||||||
import ResourceState from "@/components/ResourceState"
|
import ResourceState from "@/components/ResourceState"
|
||||||
import useMinLoading from "@/components/useMinLoading"
|
import useMinLoading from "@/lib/useMinLoading"
|
||||||
import { primeProfiles, useAdminTenant, useAdminTenantRelays } from "@/lib/hooks"
|
import { useAdminTenant, useAdminTenantRelays, useProfileMetadata } from "@/lib/hooks"
|
||||||
import { eventStore } from "@/lib/state"
|
import { shortenPubkey } from "@/lib/pubkey"
|
||||||
|
|
||||||
function shortenPubkey(pubkey: string) {
|
|
||||||
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminTenantDetail() {
|
export default function AdminTenantDetail() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -19,28 +15,7 @@ export default function AdminTenantDetail() {
|
|||||||
const [tenant] = useAdminTenant(tenantId)
|
const [tenant] = useAdminTenant(tenantId)
|
||||||
const [relays] = useAdminTenantRelays(tenantId)
|
const [relays] = useAdminTenantRelays(tenantId)
|
||||||
const loading = useMinLoading(() => tenant.loading || relays.loading)
|
const loading = useMinLoading(() => tenant.loading || relays.loading)
|
||||||
const [profile, setProfile] = createSignal<{ name?: string; picture?: string }>({})
|
const metadata = useProfileMetadata(tenantId)
|
||||||
|
|
||||||
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 churnedLabel = () => {
|
const churnedLabel = () => {
|
||||||
const ts = tenant()?.churned_at
|
const ts = tenant()?.churned_at
|
||||||
@@ -53,17 +28,17 @@ export default function AdminTenantDetail() {
|
|||||||
<BackLink href="/admin/tenants" label="Tenants" />
|
<BackLink href="/admin/tenants" label="Tenants" />
|
||||||
<div class="flex items-center gap-4 mb-6 py-2">
|
<div class="flex items-center gap-4 mb-6 py-2">
|
||||||
<Show
|
<Show
|
||||||
when={profile().picture}
|
when={getProfilePicture(metadata())}
|
||||||
fallback={
|
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">
|
<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>
|
</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>
|
</Show>
|
||||||
<div class="min-w-0">
|
<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>
|
<p class="text-xs text-gray-500 break-all">{tenantId()}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,58 +1,33 @@
|
|||||||
import { A } from "@solidjs/router"
|
import { A } from "@solidjs/router"
|
||||||
import Fuse from "fuse.js"
|
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
|
||||||
import { getProfilePicture } from "applesauce-core/helpers/profile"
|
import { getProfilePicture } from "applesauce-core/helpers/profile"
|
||||||
import PageContainer from "@/components/PageContainer"
|
import PageContainer from "@/components/PageContainer"
|
||||||
import ResourceState from "@/components/ResourceState"
|
import ResourceState from "@/components/ResourceState"
|
||||||
import SearchInput from "@/components/SearchInput"
|
import SearchInput from "@/components/SearchInput"
|
||||||
import useMinLoading from "@/components/useMinLoading"
|
import useMinLoading from "@/lib/useMinLoading"
|
||||||
import { primeProfiles, useAdminTenants } from "@/lib/hooks"
|
import { useAdminTenants, useProfileMetadataMap } from "@/lib/hooks"
|
||||||
import { eventStore } from "@/lib/state"
|
import { shortenPubkey } from "@/lib/pubkey"
|
||||||
|
import { fuzzySearch } from "@/lib/search"
|
||||||
function shortenPubkey(pubkey: string) {
|
|
||||||
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminTenantList() {
|
export default function AdminTenantList() {
|
||||||
const [query, setQuery] = createSignal("")
|
const [query, setQuery] = createSignal("")
|
||||||
const [tenants] = useAdminTenants()
|
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 loading = useMinLoading(() => tenants.loading)
|
||||||
|
|
||||||
const filtered = createMemo(() => {
|
const filtered = createMemo(() => {
|
||||||
const list = (tenants() ?? []).map((tenant) => {
|
const list = (tenants() ?? []).map((tenant) => {
|
||||||
const profile = profiles()[tenant.pubkey]
|
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()
|
const q = query().trim()
|
||||||
if (!q) return list
|
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)
|
return fuzzySearch(list, ["pubkey", "status", "profileName", "profileAbout", "profileNip05"], q)
|
||||||
})
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
@@ -68,19 +43,20 @@ export default function AdminTenantList() {
|
|||||||
<For each={filtered()}>
|
<For each={filtered()}>
|
||||||
{(tenant) => {
|
{(tenant) => {
|
||||||
const profile = () => profiles()[tenant.pubkey]
|
const profile = () => profiles()[tenant.pubkey]
|
||||||
|
const profileName = () => profile()?.name || profile()?.display_name
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<A href={`/admin/tenants/${tenant.pubkey}`} class="block border border-gray-200 rounded-lg p-4 bg-white hover:border-gray-300">
|
<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="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex items-start gap-3">
|
<div class="min-w-0 flex items-start gap-3">
|
||||||
<Show
|
<Show
|
||||||
when={profile()?.picture}
|
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">{(profile()?.name || tenant.pubkey).slice(0, 1).toUpperCase()}</div>}
|
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>
|
</Show>
|
||||||
<div class="min-w-0">
|
<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-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>
|
<p class="mt-1 text-xs text-gray-500 break-all">{tenant.pubkey}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ import PaymentDialog from "@/components/PaymentDialog"
|
|||||||
import PaymentSetup from "@/components/PaymentSetup"
|
import PaymentSetup from "@/components/PaymentSetup"
|
||||||
import RelayDetailCard from "@/components/RelayDetailCard"
|
import RelayDetailCard from "@/components/RelayDetailCard"
|
||||||
import ResourceState from "@/components/ResourceState"
|
import ResourceState from "@/components/ResourceState"
|
||||||
import useMinLoading from "@/components/useMinLoading"
|
import useMinLoading from "@/lib/useMinLoading"
|
||||||
import ActivityFeed from "@/components/ActivityFeed"
|
import ActivityFeed from "@/components/ActivityFeed"
|
||||||
import { listRelayMembers } from "@/lib/api"
|
import { listRelayMembers } from "@/lib/api"
|
||||||
import { useRelay, useRelayActivity } from "@/lib/hooks"
|
import { useRelay, useRelayActivity } from "@/lib/hooks"
|
||||||
import useRelayToggles from "@/lib/useRelayToggles"
|
import useRelayToggles from "@/lib/useRelayToggles"
|
||||||
import { setBillingFlowActive } from "@/lib/billing"
|
import { refetchBilling, setBillingFlowActive } from "@/lib/state"
|
||||||
import { refetchBilling } from "@/lib/state"
|
|
||||||
|
|
||||||
export default function RelayDetail() {
|
export default function RelayDetail() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
|
|||||||
import BackLink from "@/components/BackLink"
|
import BackLink from "@/components/BackLink"
|
||||||
import PageContainer from "@/components/PageContainer"
|
import PageContainer from "@/components/PageContainer"
|
||||||
import ResourceState from "@/components/ResourceState"
|
import ResourceState from "@/components/ResourceState"
|
||||||
import useMinLoading from "@/components/useMinLoading"
|
import useMinLoading from "@/lib/useMinLoading"
|
||||||
import { updateRelayById, useRelay } from "@/lib/hooks"
|
import { updateRelayById, useRelay } from "@/lib/hooks"
|
||||||
|
|
||||||
export default function RelayEdit(props: { basePath?: string; title?: string }) {
|
export default function RelayEdit(props: { basePath?: string; title?: string }) {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { A } from "@solidjs/router"
|
import { A } from "@solidjs/router"
|
||||||
import Fuse from "fuse.js"
|
|
||||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||||
import PageContainer from "@/components/PageContainer"
|
import PageContainer from "@/components/PageContainer"
|
||||||
import RelayListItem from "@/components/RelayListItem"
|
import RelayListItem from "@/components/RelayListItem"
|
||||||
import ResourceState from "@/components/ResourceState"
|
import ResourceState from "@/components/ResourceState"
|
||||||
import SearchInput from "@/components/SearchInput"
|
import SearchInput from "@/components/SearchInput"
|
||||||
import useMinLoading from "@/components/useMinLoading"
|
import useMinLoading from "@/lib/useMinLoading"
|
||||||
import { useTenantRelays } from "@/lib/hooks"
|
import { useTenantRelays } from "@/lib/hooks"
|
||||||
|
import { fuzzySearch } from "@/lib/search"
|
||||||
|
|
||||||
export default function RelayList() {
|
export default function RelayList() {
|
||||||
const [relays] = useTenantRelays()
|
const [relays] = useTenantRelays()
|
||||||
@@ -17,9 +17,7 @@ export default function RelayList() {
|
|||||||
const filtered = createMemo(() => {
|
const filtered = createMemo(() => {
|
||||||
const list = relays() ?? []
|
const list = relays() ?? []
|
||||||
const q = query().trim()
|
const q = query().trim()
|
||||||
const searched = q
|
const searched = fuzzySearch(list, ["info_name", "subdomain"], q)
|
||||||
? new Fuse(list, { keys: ["info_name", "subdomain"], threshold: 0.35, ignoreLocation: true }).search(q).map(r => r.item)
|
|
||||||
: list
|
|
||||||
return status() === "all" ? searched : searched.filter(r => r.status === status())
|
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 RelayForm, { type RelayFormValues } from "@/components/RelayForm"
|
||||||
import { createRelayForActiveTenant, getLatestOpenInvoice, tenantNeedsPaymentSetup } from "@/lib/hooks"
|
import { createRelayForActiveTenant, getLatestOpenInvoice, tenantNeedsPaymentSetup } from "@/lib/hooks"
|
||||||
import type { Invoice } from "@/lib/api"
|
import type { Invoice } from "@/lib/api"
|
||||||
import { setBillingFlowActive } from "@/lib/billing"
|
import { decidePostPaidFlow } from "@/lib/relayPlanFlow"
|
||||||
import { refetchBilling } from "@/lib/state"
|
import { refetchBilling, setBillingFlowActive } from "@/lib/state"
|
||||||
|
|
||||||
export default function RelayNew() {
|
export default function RelayNew() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -38,18 +38,21 @@ export default function RelayNew() {
|
|||||||
void refetchBilling()
|
void refetchBilling()
|
||||||
|
|
||||||
if (paid) {
|
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()
|
const needsSetup = await tenantNeedsPaymentSetup()
|
||||||
if (needsSetup) {
|
const invoice = needsSetup ? await getLatestOpenInvoice() : null
|
||||||
// Materialize the invoice for this change (no collection, no DM) so we
|
const decision = decidePostPaidFlow({ needsSetup, invoice })
|
||||||
// can prompt the tenant to pay it directly. listTenantInvoices reconciles
|
switch (decision.kind) {
|
||||||
// first, so a just-created invoice is visible here.
|
case "pay_invoice":
|
||||||
const invoice = await getLatestOpenInvoice()
|
setPendingInvoice(decision.invoice)
|
||||||
if (invoice) {
|
|
||||||
setPendingInvoice(invoice)
|
|
||||||
return
|
return
|
||||||
}
|
case "setup":
|
||||||
setPaymentSetupOpen(true)
|
setPaymentSetupOpen(true)
|
||||||
return
|
return
|
||||||
|
case "navigate":
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+83
-284
@@ -5,11 +5,17 @@ import { PasswordSigner } from "applesauce-signers"
|
|||||||
import QrScanner from "qr-scanner"
|
import QrScanner from "qr-scanner"
|
||||||
import QRCode from "qrcode"
|
import QRCode from "qrcode"
|
||||||
import { accountManager, ensureSessionTenant, identity, PLATFORM_NAME } from "@/lib/state"
|
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']
|
const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc']
|
||||||
|
|
||||||
type Tab = "nip07" | "nip46" | "key"
|
|
||||||
type Screen = "tabs" | "nip46" | "key"
|
type Screen = "tabs" | "nip46" | "key"
|
||||||
type SignerTab = "qr" | "paste"
|
type SignerTab = "qr" | "paste"
|
||||||
type KeyTab = "plaintext" | "encrypted"
|
type KeyTab = "plaintext" | "encrypted"
|
||||||
@@ -20,24 +26,6 @@ type LoginProps = {
|
|||||||
onAuthenticated?: () => void | Promise<void>
|
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() {
|
async function loadNostrConnectSigner() {
|
||||||
return import("applesauce-signers").then((m) => m.NostrConnectSigner)
|
return import("applesauce-signers").then((m) => m.NostrConnectSigner)
|
||||||
}
|
}
|
||||||
@@ -46,7 +34,7 @@ type LoginPageProps = LoginProps & Partial<RouteSectionProps<unknown>>
|
|||||||
|
|
||||||
export default function Login(props: LoginPageProps = {}) {
|
export default function Login(props: LoginPageProps = {}) {
|
||||||
const navigate = useNavigate()
|
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 [rawLoading, setRawLoading] = createSignal(false)
|
||||||
const loading = useMinLoading(() => rawLoading())
|
const loading = useMinLoading(() => rawLoading())
|
||||||
const [error, setError] = createSignal("")
|
const [error, setError] = createSignal("")
|
||||||
@@ -78,23 +66,29 @@ export default function Login(props: LoginPageProps = {}) {
|
|||||||
await props.onAuthenticated?.()
|
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("")
|
setError("")
|
||||||
setRawLoading(true)
|
setRawLoading(true)
|
||||||
try {
|
try {
|
||||||
await completeLogin(await ExtensionAccount.fromExtension())
|
await fn()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to login with extension")
|
setError(e instanceof Error ? e.message : fallbackMessage)
|
||||||
} finally {
|
} finally {
|
||||||
setRawLoading(false)
|
setRawLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startNostrConnect() {
|
function loginWithNip07() {
|
||||||
setError("")
|
return runLogin(async () => {
|
||||||
setRawLoading(true)
|
await completeLogin(await ExtensionAccount.fromExtension())
|
||||||
|
}, "Failed to login with extension")
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
function startNostrConnect() {
|
||||||
|
return runLogin(async () => {
|
||||||
const NostrConnectSigner = await loadNostrConnectSigner()
|
const NostrConnectSigner = await loadNostrConnectSigner()
|
||||||
const signer = new NostrConnectSigner({ relays: NIP46_RELAYS })
|
const signer = new NostrConnectSigner({ relays: NIP46_RELAYS })
|
||||||
const uri = signer.getNostrConnectURI({
|
const uri = signer.getNostrConnectURI({
|
||||||
@@ -115,54 +109,43 @@ export default function Login(props: LoginPageProps = {}) {
|
|||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeout)
|
window.clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
}, "Failed to connect signer")
|
||||||
setError(e instanceof Error ? e.message : "Failed to connect signer")
|
|
||||||
} finally {
|
|
||||||
setRawLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginWithBunker() {
|
function loginWithBunker() {
|
||||||
setError("")
|
setError("")
|
||||||
setRawLoading(true)
|
const validationError = validateBunkerUri(bunkerUrl())
|
||||||
try {
|
if (validationError) {
|
||||||
|
setError(validationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return runLogin(async () => {
|
||||||
const uri = normalizeBunkerUrl(bunkerUrl())
|
const uri = normalizeBunkerUrl(bunkerUrl())
|
||||||
const NostrConnectSigner = await loadNostrConnectSigner()
|
const NostrConnectSigner = await loadNostrConnectSigner()
|
||||||
const signer = await NostrConnectSigner.fromBunkerURI(uri)
|
const signer = await NostrConnectSigner.fromBunkerURI(uri)
|
||||||
const pubkey = await signer.getPublicKey()
|
const pubkey = await signer.getPublicKey()
|
||||||
const account = new NostrConnectAccount(pubkey, signer)
|
const account = new NostrConnectAccount(pubkey, signer)
|
||||||
await completeLogin(account)
|
await completeLogin(account)
|
||||||
} catch (e) {
|
}, "Invalid bunker URL")
|
||||||
setError(e instanceof Error ? e.message : "Invalid bunker URL")
|
|
||||||
} finally {
|
|
||||||
setRawLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginWithKeyMaterial() {
|
function loginWithKeyMaterial() {
|
||||||
setError("")
|
return runLogin(async () => {
|
||||||
setRawLoading(true)
|
const plan = decideKeyLogin({ ncryptsec: ncryptsecValue(), nsec: nsecValue(), password: password() })
|
||||||
try {
|
switch (plan.kind) {
|
||||||
if (ncryptsecValue().trim()) {
|
case "error":
|
||||||
if (!password().trim()) {
|
throw new Error(plan.message)
|
||||||
throw new Error("Password is required for ncryptsec")
|
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())
|
case "nsec":
|
||||||
const pubkey = await signer.getPublicKey()
|
await completeLogin(PrivateKeyAccount.fromKey(plan.key))
|
||||||
const account = new PasswordAccount(pubkey, signer)
|
break
|
||||||
await completeLogin(account)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}, "Invalid key")
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeScanner() {
|
function closeScanner() {
|
||||||
@@ -203,7 +186,7 @@ export default function Login(props: LoginPageProps = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function copyUri() {
|
function copyUri() {
|
||||||
void navigator.clipboard.writeText(nostrConnectUri())
|
void copyToClipboard(nostrConnectUri(), { successMessage: "Link copied" })
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -261,219 +244,47 @@ export default function Login(props: LoginPageProps = {}) {
|
|||||||
|
|
||||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-6">
|
<div class="rounded-xl border border-gray-200 bg-white/80 p-6">
|
||||||
<Show when={screen() === "tabs"}>
|
<Show when={screen() === "tabs"}>
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Log in / Sign up</h2>
|
<LoginTabsScreen
|
||||||
<p class="mt-2 text-xs text-gray-500">
|
tab={tab}
|
||||||
Use any Nostr signer method. New users are automatically onboarded.
|
setTab={setTab}
|
||||||
</p>
|
loading={loading}
|
||||||
<div class="mt-6 space-y-4">
|
hasExtension={Boolean(window.nostr)}
|
||||||
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
|
onContinueExtension={loginWithNip07}
|
||||||
<button
|
onContinueSigner={enterSignerScreen}
|
||||||
type="button"
|
onContinueKey={() => { setError(""); setScreen("key") }}
|
||||||
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>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={screen() === "nip46"}>
|
<Show when={screen() === "nip46"}>
|
||||||
<button
|
<LoginSignerScreen
|
||||||
type="button"
|
signerTab={signerTab}
|
||||||
class="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
|
setSignerTab={setSignerTab}
|
||||||
onClick={() => { setError(""); setScreen("tabs") }}
|
qrDataUrl={qrDataUrl}
|
||||||
>
|
nostrConnectUri={nostrConnectUri}
|
||||||
<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>
|
bunkerUrl={bunkerUrl}
|
||||||
Back
|
setBunkerUrl={setBunkerUrl}
|
||||||
</button>
|
loading={loading}
|
||||||
<h2 class="mt-3 text-lg font-semibold text-gray-900">Log in with signer</h2>
|
onBack={() => { setError(""); setScreen("tabs") }}
|
||||||
<div class="mt-4 space-y-4">
|
onCopyUri={copyUri}
|
||||||
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
|
onScan={openScanner}
|
||||||
<button
|
onConnectBunker={loginWithBunker}
|
||||||
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>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={screen() === "key"}>
|
<Show when={screen() === "key"}>
|
||||||
<button
|
<LoginKeyScreen
|
||||||
type="button"
|
keyTab={keyTab}
|
||||||
class="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
|
setKeyTab={setKeyTab}
|
||||||
onClick={() => { setError(""); setScreen("tabs") }}
|
nsecValue={nsecValue}
|
||||||
>
|
setNsecValue={setNsecValue}
|
||||||
<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>
|
ncryptsecValue={ncryptsecValue}
|
||||||
Back
|
setNcryptsecValue={setNcryptsecValue}
|
||||||
</button>
|
password={password}
|
||||||
<h2 class="mt-3 text-lg font-semibold text-gray-900">Log in with key</h2>
|
setPassword={setPassword}
|
||||||
<div class="mt-4 space-y-4">
|
loading={loading}
|
||||||
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
|
onBack={() => { setError(""); setScreen("tabs") }}
|
||||||
<button
|
onSubmit={() => void loginWithKeyMaterial()}
|
||||||
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>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
@@ -487,19 +298,7 @@ export default function Login(props: LoginPageProps = {}) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={showScanner()}>
|
<QrScannerOverlay open={showScanner()} onClose={closeScanner} videoRef={(el) => { scannerVideo = el }} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user