Split payment setup into separate components
This commit is contained in:
@@ -4,6 +4,9 @@ SERVER_PORT=2892
|
||||
SERVER_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
SERVER_ADMIN_PUBKEYS=
|
||||
|
||||
# Frontend
|
||||
APP_URL=http://127.0.0.1:5173
|
||||
|
||||
# Database
|
||||
DATABASE_URL=sqlite://data/caravel.db
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import Modal from "@/components/Modal"
|
||||
import { createPortalSession } from "@/lib/api"
|
||||
import { account } from "@/lib/state"
|
||||
|
||||
type PaymentSetupCardProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
// The tenant already has a card on file, so the copy frames this as managing
|
||||
// the existing one rather than adding a first card.
|
||||
isUpdate?: boolean
|
||||
}
|
||||
|
||||
// Focused card dialog. PaymentSetup offers both methods behind tabs for the
|
||||
// general setup flow; here the entry point is explicitly "manage your card", so
|
||||
// there's no method switcher — adding/updating a card is a redirect to the
|
||||
// Stripe billing portal, which returns to wherever it was opened from.
|
||||
export default function PaymentSetupCard(props: PaymentSetupCardProps) {
|
||||
const [redirecting, setRedirecting] = createSignal(false)
|
||||
const [error, setError] = createSignal("")
|
||||
|
||||
async function openPortal() {
|
||||
setRedirecting(true)
|
||||
setError("")
|
||||
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")
|
||||
setRedirecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setError("")
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={props.open}
|
||||
onClose={handleClose}
|
||||
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
panelClass="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
|
||||
>
|
||||
<div class="px-6 pt-6 pb-4 border-b border-gray-100">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">
|
||||
{props.isUpdate ? "Manage Card" : "Add a Card"}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{props.isUpdate
|
||||
? "Manage your saved card in the Stripe billing portal."
|
||||
: "Add a card via the Stripe billing portal to pay invoices automatically."}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
class="text-gray-400 hover:text-gray-700 rounded p-1 hover:bg-gray-100 flex-shrink-0"
|
||||
aria-label="Close"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 min-h-[180px] flex flex-col justify-center">
|
||||
<div class="text-center space-y-4">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
|
||||
<svg class="w-6 h-6 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">
|
||||
{props.isUpdate
|
||||
? "Update or remove your card in the Stripe billing portal. We'll retry any due invoice after you're done."
|
||||
: "Add a payment card via Stripe to enable automatic billing. If an invoice is currently due, we will retry collection after card setup."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPortal}
|
||||
disabled={redirecting()}
|
||||
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{redirecting() ? "Redirecting..." : props.isUpdate ? "Manage card" : "Add a payment card"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="px-6 pb-2">
|
||||
<p class="text-xs text-red-600">{error()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="px-6 py-4 border-t border-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
class="py-2 px-4 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import Modal from "@/components/Modal"
|
||||
import { updateActiveTenant } from "@/lib/hooks"
|
||||
|
||||
type PaymentSetupNWCProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved?: () => void
|
||||
// The tenant already has a wallet connected, so the copy frames this as
|
||||
// replacing it (the stored URL is write-only and never sent back).
|
||||
isUpdate?: boolean
|
||||
}
|
||||
|
||||
// Focused Lightning/NWC connect dialog. PaymentSetup offers both methods behind
|
||||
// tabs for the general setup flow; here the entry point is explicitly "connect a
|
||||
// Lightning wallet", so there's no method switcher — the card path lives on its
|
||||
// own row that redirects to Stripe.
|
||||
export default function PaymentSetupNWC(props: PaymentSetupNWCProps) {
|
||||
const [nwcUrl, setNwcUrl] = createSignal("")
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
const [saved, setSaved] = createSignal(false)
|
||||
const [error, setError] = createSignal("")
|
||||
|
||||
async function save() {
|
||||
const url = nwcUrl().trim()
|
||||
if (!url) return
|
||||
setSaving(true)
|
||||
setError("")
|
||||
try {
|
||||
await updateActiveTenant({ nwc_url: url })
|
||||
setSaved(true)
|
||||
props.onSaved?.()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save wallet connection")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setNwcUrl("")
|
||||
setSaved(false)
|
||||
setError("")
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={props.open}
|
||||
onClose={handleClose}
|
||||
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
panelClass="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
|
||||
>
|
||||
<div class="px-6 pt-6 pb-4 border-b border-gray-100">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">
|
||||
{props.isUpdate ? "Update Lightning Wallet" : "Connect Lightning Wallet"}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{props.isUpdate
|
||||
? "Paste a new Nostr Wallet Connect URL to replace your connected wallet."
|
||||
: "Paste your Nostr Wallet Connect URL to pay invoices automatically over Lightning."}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
class="text-gray-400 hover:text-gray-700 rounded p-1 hover:bg-gray-100 flex-shrink-0"
|
||||
aria-label="Close"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 min-h-[180px] flex flex-col justify-center">
|
||||
<Show
|
||||
when={!saved()}
|
||||
fallback={
|
||||
<div class="text-center">
|
||||
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<svg class="w-6 h-6 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">Wallet connected!</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Automatic payments are now enabled.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700">Nostr Wallet Connect URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nwcUrl()}
|
||||
onInput={(e) => setNwcUrl(e.currentTarget.value)}
|
||||
placeholder="nostr+walletconnect://..."
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving() || !nwcUrl().trim()}
|
||||
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving() ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="px-6 pb-2">
|
||||
<p class="text-xs text-red-600">{error()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="px-6 py-4 border-t border-gray-100">
|
||||
<Show
|
||||
when={saved()}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
class="py-2 px-4 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -104,3 +104,31 @@ export function activeBillingPrompt(
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export type AccountStatus = "active" | "inactive" | "delinquent"
|
||||
|
||||
// Coarse account-health summary for the status badge. Pure function of the same
|
||||
// snapshot `activeBillingPrompt` consumes, so the badge can never disagree with
|
||||
// the prompt. Mutually exclusive and total:
|
||||
// - delinquent: churned_at is set — the ONLY frontend-visible signal of a real
|
||||
// suspension (churn_tenant is the single place relays are paused). An open
|
||||
// invoice alone is NOT delinquency: the tenant has a 7-day grace window and
|
||||
// autopay may simply not have fired yet. Matches the sole severity:"error"
|
||||
// branch in activeBillingPrompt.
|
||||
// - active: not churned AND there is paid business to keep running — an active
|
||||
// paid relay, an open balance, or a configured payment method. A failed method
|
||||
// (nwc_error/stripe_error) or an unpaid invoice within grace stays "active";
|
||||
// the per-method rows and the inline prompt carry that detail.
|
||||
// - inactive: not churned and nothing billable — no paid relay, no balance, no
|
||||
// method. The brand-new or free-only tenant (typically billing_anchor == null).
|
||||
export function accountStatus(s: BillingStatusSnapshot): AccountStatus {
|
||||
const tenant = s.tenant
|
||||
if (!tenant) return "inactive"
|
||||
if (tenant.churned_at) return "delinquent"
|
||||
|
||||
const autopayConfigured = tenant.nwc_is_set || Boolean(tenant.stripe_payment_method_id)
|
||||
const hasOpenInvoice = Boolean(s.openInvoice)
|
||||
|
||||
if (s.hasPaidSubscription || hasOpenInvoice || autopayConfigured) return "active"
|
||||
return "inactive"
|
||||
}
|
||||
|
||||
+120
-82
@@ -5,9 +5,10 @@ import PaymentDialog from "@/components/PaymentDialog"
|
||||
import BillingPrompts from "@/components/BillingPrompts"
|
||||
import useMinLoading from "@/components/useMinLoading"
|
||||
import { useInvoicePdf } from "@/lib/useInvoicePdf"
|
||||
import { updateActiveTenant } from "@/lib/hooks"
|
||||
import { useBillingStatus } from "@/lib/billing"
|
||||
import { createPortalSession, invoiceStatus, type Invoice } from "@/lib/api"
|
||||
import PaymentSetupNWC from "@/components/PaymentSetupNWC"
|
||||
import PaymentSetupCard from "@/components/PaymentSetupCard"
|
||||
import { useBillingStatus, accountStatus, type AccountStatus } from "@/lib/billing"
|
||||
import { invoiceStatus, type Invoice } from "@/lib/api"
|
||||
import { account } from "@/lib/state"
|
||||
|
||||
const methodLabels: Record<string, string> = {
|
||||
@@ -22,13 +23,25 @@ const invoiceStatusStyles: Record<string, string> = {
|
||||
void: "bg-gray-100 text-gray-500 border-gray-200",
|
||||
}
|
||||
|
||||
const accountStatusStyles: Record<AccountStatus, string> = {
|
||||
active: "bg-green-50 text-green-700 border-green-200",
|
||||
inactive: "bg-gray-100 text-gray-500 border-gray-200",
|
||||
delinquent: "bg-red-50 text-red-700 border-red-200",
|
||||
}
|
||||
|
||||
type MethodState = { status: "not set up" | "ok" | "error"; error?: string }
|
||||
|
||||
const methodStatusStyles: Record<MethodState["status"], string> = {
|
||||
"not set up": "bg-gray-100 text-gray-500 border-gray-200",
|
||||
ok: "bg-green-50 text-green-700 border-green-200",
|
||||
error: "bg-red-50 text-red-700 border-red-200",
|
||||
}
|
||||
|
||||
export default function Account() {
|
||||
const billing = useBillingStatus()
|
||||
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 [nwcModalOpen, setNwcModalOpen] = createSignal(false)
|
||||
const [cardModalOpen, setCardModalOpen] = createSignal(false)
|
||||
const invoicesLoading = useMinLoading(() => billing.loading())
|
||||
const { printInvoice, printing } = useInvoicePdf()
|
||||
|
||||
@@ -39,9 +52,31 @@ export default function Account() {
|
||||
if (account()?.pubkey) billing.refetch()
|
||||
})
|
||||
|
||||
// The backend never returns the stored nwc_url (it's private), so the input is
|
||||
// write-only: we can only act on a newly entered URL, not prefill the saved one.
|
||||
const hasBillingChanges = createMemo(() => nwcUrl().trim().length > 0)
|
||||
// Coarse account-health summary for the badge. Same snapshot the inline prompt
|
||||
// consumes, so the two can never disagree.
|
||||
const status = createMemo(() =>
|
||||
accountStatus({
|
||||
tenant: billing.tenant(),
|
||||
openInvoice: billing.openInvoice(),
|
||||
hasPaidSubscription: billing.hasPaidSubscription(),
|
||||
}),
|
||||
)
|
||||
|
||||
// Per-method state, reported independently so a concurrent error on one method
|
||||
// isn't masked by the other.
|
||||
const nwcState = createMemo<MethodState>(() => {
|
||||
const t = billing.tenant()
|
||||
if (!t?.nwc_is_set) return { status: "not set up" }
|
||||
if (t.nwc_error) return { status: "error", error: t.nwc_error }
|
||||
return { status: "ok" }
|
||||
})
|
||||
|
||||
const cardState = createMemo<MethodState>(() => {
|
||||
const t = billing.tenant()
|
||||
if (!t?.stripe_payment_method_id) return { status: "not set up" }
|
||||
if (t.stripe_error) return { status: "error", error: t.stripe_error }
|
||||
return { status: "ok" }
|
||||
})
|
||||
|
||||
// The amount to surface: the total of any open invoices, else nothing owed.
|
||||
const balance = createMemo(() => {
|
||||
@@ -49,32 +84,6 @@ export default function Account() {
|
||||
return due > 0 ? { kind: "due" as const, amount: due } : { kind: "clear" as const, amount: 0 }
|
||||
})
|
||||
|
||||
async function saveBilling() {
|
||||
setError("")
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateActiveTenant({ nwc_url: nwcUrl().trim() })
|
||||
setNwcUrl("")
|
||||
billing.refetch()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to update billing")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 = "/"
|
||||
@@ -98,31 +107,17 @@ export default function Account() {
|
||||
<BillingPrompts variant="inline" />
|
||||
|
||||
<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>
|
||||
<div class="flex items-center justify-between gap-3 mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Account & Billing</h2>
|
||||
<Show when={billing.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 class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${accountStatusStyles[status()]}`}>
|
||||
{status()}
|
||||
</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>
|
||||
|
||||
{/* Current balance */}
|
||||
<div class="mb-4 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
|
||||
{/* Current balance (relocated from the old Recurring Billing section, logic unchanged) */}
|
||||
<div class="mb-6 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Current balance</p>
|
||||
<Show
|
||||
when={balance().kind === "due"}
|
||||
@@ -132,32 +127,57 @@ export default function Account() {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Enable automatic payments by providing your Nostr Wallet Connect URL, or add a card via Manage Billing.
|
||||
</p>
|
||||
<Show when={billing.tenant()?.nwc_is_set}>
|
||||
<p class="text-sm text-green-700 mb-4">A wallet is connected. Enter a new URL to replace it.</p>
|
||||
</Show>
|
||||
<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={error()}>
|
||||
<p class="mt-3 text-sm text-red-600">{error()}</p>
|
||||
</Show>
|
||||
{/* Billing methods */}
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Billing methods</p>
|
||||
<ul class="space-y-3">
|
||||
{/* Lightning / NWC row — CTA opens the NWC modal */}
|
||||
<li class="rounded-lg border border-gray-200 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900">Lightning (NWC)</p>
|
||||
<Show when={nwcState().status === "error" && nwcState().error}>
|
||||
<p class="text-xs text-red-600 mt-0.5 break-words">{nwcState().error}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${methodStatusStyles[nwcState().status]}`}>
|
||||
{nwcState().status}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNwcModalOpen(true)}
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{nwcState().status === "not set up" ? "Set up" : "Update"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{/* Card / Stripe row — CTA opens the card modal (which redirects to the Stripe portal) */}
|
||||
<li class="rounded-lg border border-gray-200 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900">Card</p>
|
||||
<Show when={cardState().status === "error" && cardState().error}>
|
||||
<p class="text-xs text-red-600 mt-0.5 break-words">{cardState().error}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${methodStatusStyles[cardState().status]}`}>
|
||||
{cardState().status}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCardModalOpen(true)}
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{cardState().status === "not set up" ? "Set up" : "Update"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||
@@ -239,6 +259,24 @@ export default function Account() {
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<PaymentSetupNWC
|
||||
open={nwcModalOpen()}
|
||||
isUpdate={nwcState().status !== "not set up"}
|
||||
onClose={() => {
|
||||
setNwcModalOpen(false)
|
||||
billing.refetch()
|
||||
}}
|
||||
/>
|
||||
|
||||
<PaymentSetupCard
|
||||
open={cardModalOpen()}
|
||||
isUpdate={cardState().status !== "not set up"}
|
||||
onClose={() => {
|
||||
setCardModalOpen(false)
|
||||
billing.refetch()
|
||||
}}
|
||||
/>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function RelayList() {
|
||||
<div class="mb-6 grid gap-3 sm:grid-cols-[1fr_auto]">
|
||||
<SearchInput value={query()} onInput={setQuery} placeholder="Search by name or subdomain" />
|
||||
<select value={status()} onChange={e => setStatus(e.currentTarget.value)} class="border border-gray-300 rounded-lg px-3 py-2 bg-white">
|
||||
<option value="all">All statuses</option>
|
||||
<option value="all">All</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user