Files
caravel/frontend/src/pages/Account.tsx
T

203 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"
import PageContainer from "@/components/PageContainer"
import LoadingState from "@/components/LoadingState"
import PaymentDialog from "@/components/PaymentDialog"
import useMinLoading from "@/components/useMinLoading"
import { updateActiveTenant, useTenant } from "@/lib/hooks"
import { createPortalSession, listTenantInvoices, type Invoice } from "@/lib/api"
import { account } from "@/lib/state"
export default function Account() {
const [tenant, { refetch: refetchTenant }] = useTenant()
const [invoices, { refetch: refetchInvoices }] = createResource(() => listTenantInvoices(account()!.pubkey))
const [nwcUrl, setNwcUrl] = createSignal("")
const [saving, setSaving] = createSignal(false)
const [error, setError] = createSignal("")
const [selectedInvoice, setSelectedInvoice] = createSignal<Invoice | undefined>()
const [portalLoading, setPortalLoading] = createSignal(false)
const invoicesLoading = useMinLoading(() => invoices.loading)
const hasBillingChanges = createMemo(() => {
const current = tenant()?.nwc_url?.trim() ?? ""
const next = nwcUrl().trim()
return current !== next
})
createEffect(() => {
setNwcUrl(tenant()?.nwc_url ?? "")
})
async function saveBilling() {
setError("")
setSaving(true)
try {
const next = nwcUrl().trim()
await updateActiveTenant({ nwc_url: next })
await refetchTenant()
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update billing")
} finally {
setSaving(false)
}
}
function handleInvoiceDialogClose() {
setSelectedInvoice(undefined)
void refetchInvoices()
}
async function openPortal() {
setPortalLoading(true)
try {
const { url } = await createPortalSession(account()!.pubkey, window.location.href)
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal")
} finally {
setPortalLoading(false)
}
}
function logout() {
localStorage.clear()
window.location.href = "/"
}
const invoiceStatusStyles: Record<string, string> = {
draft: "bg-gray-100 text-gray-500 border-gray-200",
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
paid: "bg-green-50 text-green-700 border-green-200",
void: "bg-gray-100 text-gray-500 border-gray-200",
uncollectible: "bg-red-50 text-red-700 border-red-200",
}
return (
<PageContainer>
<div class="mb-6 py-2 flex items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-gray-900">My Account</h1>
<button
type="button"
onClick={logout}
class="py-1.5 px-3 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors"
>
Log out
</button>
</div>
<div class="space-y-6">
<section class="bg-white border border-gray-200 rounded-xl p-6">
<div class="flex items-center justify-between gap-3">
<h2 class="text-lg font-semibold text-gray-900">Account Status</h2>
<Show when={tenant()}>
<span class="rounded-full border border-gray-300 bg-gray-100 px-2.5 py-1 text-xs font-medium uppercase tracking-wide text-gray-700">
tenant
</span>
</Show>
</div>
</section>
<section class="bg-white border border-gray-200 rounded-xl p-6">
<div class="flex items-center justify-between gap-3 mb-4">
<h2 class="text-lg font-semibold text-gray-900">Recurring Billing</h2>
<button
type="button"
onClick={openPortal}
disabled={portalLoading()}
class="text-sm font-medium text-blue-600 hover:text-blue-700 disabled:opacity-50"
>
{portalLoading() ? "Loading..." : "Manage Billing"}
</button>
</div>
<p class="text-sm text-gray-600 mb-4">
Enable automatic payments by providing your Nostr Wallet Connect URL.
</p>
<div class="flex gap-2">
<input
type="text"
value={nwcUrl()}
onInput={(e) => setNwcUrl(e.currentTarget.value)}
placeholder="nostr+walletconnect://..."
class="flex-1 border border-gray-300 rounded-lg px-3 py-2"
/>
<button
type="button"
onClick={saveBilling}
disabled={saving() || !hasBillingChanges()}
class="py-2 px-4 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{saving() ? "Saving..." : "Save"}
</button>
</div>
<Show when={tenant()?.nwc_error}>
<p class="mt-3 text-sm text-red-600">{tenant()!.nwc_error}</p>
</Show>
<Show when={error()}>
<p class="mt-3 text-sm text-red-600">{error()}</p>
</Show>
</section>
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Invoice History</h2>
<Show when={invoicesLoading()}>
<LoadingState message="Loading invoices..." paddingClass="py-8" />
</Show>
<Show when={!invoicesLoading()}>
<Show when={(invoices()?.length ?? 0) > 0} fallback={<p class="text-gray-500 text-sm">No invoices yet.</p>}>
<ul class="space-y-3">
<For each={invoices()}>
{(invoice) => {
const isOpen = () => invoice.status === "open"
const statusStyle = () => invoiceStatusStyles[invoice.status] ?? "bg-gray-100 text-gray-500 border-gray-200"
const periodLabel = () => {
const start = new Date(invoice.period_start * 1000)
const end = new Date(invoice.period_end * 1000)
return `${start.toLocaleDateString()} ${end.toLocaleDateString()}`
}
return (
<li
class={`rounded-lg border border-gray-200 p-4 text-sm ${isOpen() ? "cursor-pointer hover:border-blue-300 hover:bg-blue-50 transition-colors" : ""}`}
onClick={() => isOpen() && setSelectedInvoice(invoice)}
title={isOpen() ? "Click to pay this invoice" : undefined}
>
<div class="flex items-center justify-between gap-3">
<div>
<span class="font-medium text-gray-900">
${(invoice.amount_due / 100).toFixed(2)}
</span>
<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()}>
<span class="text-xs text-blue-600 font-medium">Pay now</span>
</Show>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${statusStyle()}`}>
{invoice.status}
</span>
</div>
</div>
</li>
)
}}
</For>
</ul>
</Show>
</Show>
</section>
</div>
<Show when={selectedInvoice()}>
{(invoice) => (
<PaymentDialog
invoice={invoice()}
open={true}
onClose={handleInvoiceDialogClose}
/>
)}
</Show>
</PageContainer>
)
}