diff --git a/frontend/src/components/PaymentDialog.tsx b/frontend/src/components/PaymentDialog.tsx index 14d9c78..e033ece 100644 --- a/frontend/src/components/PaymentDialog.tsx +++ b/frontend/src/components/PaymentDialog.tsx @@ -2,11 +2,14 @@ import { createEffect, createResource, createSignal, For, Show } from "solid-js" import QRCode from "qrcode" import Modal from "@/components/Modal" import PaymentSetup from "@/components/PaymentSetup" +import { CardSetupBody } from "@/components/PaymentSetupShell" +import { useCardPortal } from "@/lib/usePaymentSetup" import { getInvoice, getInvoiceBolt11, listInvoiceItems, type Invoice } from "@/lib/api" import { billingTenant } from "@/lib/state" type PayStatus = "idle" | "loading" | "success" | "error" type Bolt11Status = "idle" | "loading" | "ready" | "error" +type PayMethod = "lightning" | "card" type PaymentInvoice = Pick & Partial> @@ -24,6 +27,7 @@ export default function PaymentDialog(props: PaymentDialogProps) { const [bolt11Error, setBolt11Error] = createSignal("") const [payStatus, setPayStatus] = createSignal("idle") const [payError, setPayError] = createSignal("") + const [payMethod, setPayMethod] = createSignal("lightning") const [showPaymentSetup, setShowPaymentSetup] = createSignal(false) const [setupSaved, setSetupSaved] = createSignal(false) const [items] = createResource( @@ -31,6 +35,10 @@ export default function PaymentDialog(props: PaymentDialogProps) { listInvoiceItems, ) + // Card payment is a redirect to the Stripe billing portal; once a card is on + // file we retry collection on this invoice automatically. + const card = useCardPortal() + const autopayConfigured = () => { const t = billingTenant() return Boolean(t?.nwc_is_set || t?.stripe_payment_method_id) @@ -87,6 +95,8 @@ export default function PaymentDialog(props: PaymentDialogProps) { setBolt11Error("") setBolt11("") setQrDataUrl("") + setPayMethod("lightning") + card.reset() props.onClose() } @@ -154,59 +164,75 @@ export default function PaymentDialog(props: PaymentDialogProps) { -

Pay with Lightning

- -
Generating invoice...
-
- -
-

Unable to generate invoice

-

{bolt11Error()}

- -
-
- - Lightning invoice QR code - -
- + {/* Method switcher */} +
+ + +
+ + {/* Lightning: pay this invoice via a bolt11 QR */} + + +
Generating invoice...
+
+ +
+

Unable to generate invoice

+

{bolt11Error()}

+ + Lightning invoice QR code + +
+ + +
+
+

+ Scan this QR code with a Bitcoin Lightning wallet to pay. +

+
- {/* Card / automatic payment alternative */} -
-

Prefer to pay with a card?

- -
+ {/* Card: redirect to the Stripe billing portal */} + + +
} > @@ -232,9 +258,9 @@ export default function PaymentDialog(props: PaymentDialogProps) { {/* Error */} - +
-

{payError()}

+

{payError() || card.error()}

@@ -261,14 +287,16 @@ export default function PaymentDialog(props: PaymentDialogProps) { > Pay Later - + + +
diff --git a/frontend/src/components/PaymentSetup.tsx b/frontend/src/components/PaymentSetup.tsx index 7449977..27cb694 100644 --- a/frontend/src/components/PaymentSetup.tsx +++ b/frontend/src/components/PaymentSetup.tsx @@ -1,8 +1,6 @@ import { createEffect, createSignal, Show } from "solid-js" -import Modal from "@/components/Modal" -import { updateActiveTenant } from "@/lib/hooks" -import { createPortalSession } from "@/lib/api" -import { account } from "@/lib/state" +import { PaymentSetupShell, PaymentSetupBody, SetupFooter, NwcSetupBody, CardSetupBody } from "@/components/PaymentSetupShell" +import { useCardPortal, useNwcSetup } from "@/lib/usePaymentSetup" type Tab = "nwc" | "card" @@ -22,73 +20,29 @@ export default function PaymentSetup(props: PaymentSetupProps) { createEffect(() => { if (props.open) setTab(props.initialTab ?? "nwc") }) - const [nwcUrl, setNwcUrl] = createSignal("") - const [saving, setSaving] = createSignal(false) - const [saved, setSaved] = createSignal(false) - const [error, setError] = createSignal("") - const [redirecting, setRedirecting] = createSignal(false) - async function saveNwc() { - 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) - } - } + const nwc = useNwcSetup(() => props.onSaved?.()) + const card = useCardPortal() - 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) - } - } + // Surface only the active tab's error so a stale failure on one method doesn't + // bleed into the other. + const error = () => (tab() === "nwc" ? nwc.error() : card.error()) function handleClose() { - setNwcUrl("") - setSaved(false) - setError("") + nwc.reset() + card.reset() props.onClose() } return ( - } > -
-
-
-

Set Up Payments

-

Choose how you'd like to pay once invoices are issued for your relay.

-
- -
-
-

Pay with

@@ -109,92 +63,14 @@ export default function PaymentSetup(props: PaymentSetupProps) {
-
+ - -
- - - -
-

Wallet connected!

-

Automatic payments are now enabled.

-
- } - > -
- - setNwcUrl(e.currentTarget.value)} - placeholder="nostr+walletconnect://..." - class="w-full border border-gray-300 rounded-lg px-3 py-2" - /> - -
-
+
- -
-
- - - - -
-

Add a payment card via Stripe to enable automatic billing. If an invoice is currently due, we will retry collection after card setup.

- -
+
- - - -
-

{error()}

-
-
- -
- -
- -
-
- - - -
- + + ) } diff --git a/frontend/src/components/PaymentSetupCard.tsx b/frontend/src/components/PaymentSetupCard.tsx index 3149c15..ee113c2 100644 --- a/frontend/src/components/PaymentSetupCard.tsx +++ b/frontend/src/components/PaymentSetupCard.tsx @@ -1,7 +1,5 @@ -import { createSignal, Show } from "solid-js" -import Modal from "@/components/Modal" -import { createPortalSession } from "@/lib/api" -import { account } from "@/lib/state" +import { PaymentSetupShell, PaymentSetupBody, SetupFooter, CardSetupBody } from "@/components/PaymentSetupShell" +import { useCardPortal } from "@/lib/usePaymentSetup" type PaymentSetupCardProps = { open: boolean @@ -16,97 +14,29 @@ type PaymentSetupCardProps = { // 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) - } - } + const card = useCardPortal() function handleClose() { - setError("") + card.reset() props.onClose() } return ( - } > -
-
-
-

- {props.isUpdate ? "Manage Card" : "Add a Card"} -

-

- {props.isUpdate - ? "Manage your saved card in the Stripe billing portal." - : "Add a card via the Stripe billing portal to pay invoices automatically."} -

-
- -
-
- -
-
-
- - - - -
-

- {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."} -

- -
-
- - -
-

{error()}

-
-
- -
- -
-
+ + + + ) } diff --git a/frontend/src/components/PaymentSetupNWC.tsx b/frontend/src/components/PaymentSetupNWC.tsx index bef7913..4419b5b 100644 --- a/frontend/src/components/PaymentSetupNWC.tsx +++ b/frontend/src/components/PaymentSetupNWC.tsx @@ -1,6 +1,5 @@ -import { createSignal, Show } from "solid-js" -import Modal from "@/components/Modal" -import { updateActiveTenant } from "@/lib/hooks" +import { PaymentSetupShell, PaymentSetupBody, SetupFooter, NwcSetupBody } from "@/components/PaymentSetupShell" +import { useNwcSetup } from "@/lib/usePaymentSetup" type PaymentSetupNWCProps = { open: boolean @@ -16,132 +15,29 @@ type PaymentSetupNWCProps = { // 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) - } - } + const nwc = useNwcSetup(() => props.onSaved?.()) function handleClose() { - setNwcUrl("") - setSaved(false) - setError("") + nwc.reset() props.onClose() } return ( - } > -
-
-
-

- {props.isUpdate ? "Update Lightning Wallet" : "Connect Lightning Wallet"} -

-

- {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."} -

-
- -
-
- -
- -
- - - -
-

Wallet connected!

-

Automatic payments are now enabled.

-
- } - > -
- - setNwcUrl(e.currentTarget.value)} - placeholder="nostr+walletconnect://..." - class="w-full border border-gray-300 rounded-lg px-3 py-2" - /> - -
- - - - -
-

{error()}

-
-
- -
- - Cancel - - } - > -
- -
-
-
-
+ + + + ) } diff --git a/frontend/src/components/PaymentSetupShell.tsx b/frontend/src/components/PaymentSetupShell.tsx new file mode 100644 index 0000000..8236e1a --- /dev/null +++ b/frontend/src/components/PaymentSetupShell.tsx @@ -0,0 +1,159 @@ +import { Show, type JSX } from "solid-js" +import Modal from "@/components/Modal" +import type { CardPortal, NwcSetup } from "@/lib/usePaymentSetup" + +type PaymentSetupShellProps = { + open: boolean + onClose: () => void + title: string + description: string + error?: string + footer: JSX.Element + children: JSX.Element +} + +// Shared chrome for the payment-setup dialogs: the modal frame, the +// title/description header with a close button, the error line, and the footer +// container. Each caller supplies the body (children) and footer buttons. +export function PaymentSetupShell(props: PaymentSetupShellProps) { + return ( + +
+
+
+

{props.title}

+

{props.description}

+
+ +
+
+ + {props.children} + + +
+

{props.error}

+
+
+ +
{props.footer}
+
+ ) +} + +// The fixed-height content region between the header and footer. +export function PaymentSetupBody(props: { children: JSX.Element }) { + return
{props.children}
+} + +// Footer for every payment-setup dialog: a "Done" confirm once an action has +// succeeded, otherwise a secondary dismiss button. Card setup never "saves" (it +// redirects away), so it always shows the dismiss button. +export function SetupFooter(props: { saved?: boolean; cancelLabel: string; onClose: () => void }) { + return ( + + {props.cancelLabel} + + } + > +
+ +
+
+ ) +} + +// Lightning/NWC body: the URL input + save, or the success state once saved. +export function NwcSetupBody(props: { nwc: NwcSetup }) { + const nwc = props.nwc + return ( + +
+ + + +
+

Wallet connected!

+

Automatic payments are now enabled.

+ + } + > +
+ + nwc.setNwcUrl(e.currentTarget.value)} + placeholder="nostr+walletconnect://..." + class="w-full border border-gray-300 rounded-lg px-3 py-2" + /> + +
+
+ ) +} + +// Card body: an explanation plus the button that redirects to the Stripe portal. +// `isUpdate` adjusts the copy for tenants who already have a card on file. +export function CardSetupBody(props: { card: CardPortal; isUpdate?: boolean }) { + return ( +
+
+ + + + +
+

+ {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."} +

+ +
+ ) +} diff --git a/frontend/src/components/RelayListItem.tsx b/frontend/src/components/RelayListItem.tsx index 8227f9d..cc3fd81 100644 --- a/frontend/src/components/RelayListItem.tsx +++ b/frontend/src/components/RelayListItem.tsx @@ -28,7 +28,7 @@ export default function RelayListItem(props: RelayListItemProps) { class="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-0.5 text-xs font-medium text-red-700 max-w-56 truncate" title={props.relay.sync_error} > - {props.relay.sync_error} + Failed to sync diff --git a/frontend/src/index.css b/frontend/src/index.css index f9622b1..fb6b113 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -23,4 +23,7 @@ --color-blue-500: #c18254; --color-blue-600: #c18254; --color-blue-700: #a66d46; + --color-blue-800: #8a5a39; + --color-blue-900: #6f4730; + --color-blue-950: #45291a; } diff --git a/frontend/src/lib/usePaymentSetup.ts b/frontend/src/lib/usePaymentSetup.ts new file mode 100644 index 0000000..2cacb61 --- /dev/null +++ b/frontend/src/lib/usePaymentSetup.ts @@ -0,0 +1,67 @@ +import { createSignal } from "solid-js" +import { updateActiveTenant } from "@/lib/hooks" +import { createPortalSession } from "@/lib/api" +import { account } from "@/lib/state" + +// Lightning/NWC save state machine, shared by the combined and focused setup +// dialogs. `onSaved` fires once the wallet URL is persisted. +export function useNwcSetup(onSaved?: () => void) { + 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) + onSaved?.() + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save wallet connection") + } finally { + setSaving(false) + } + } + + function reset() { + setNwcUrl("") + setSaved(false) + setError("") + } + + return { nwcUrl, setNwcUrl, saving, saved, error, save, reset } +} + +export type NwcSetup = ReturnType + +// Card setup is a full-page redirect to the Stripe billing portal (which returns +// to wherever it was opened from), so there's no local "saved" state — only the +// in-flight redirect and any failure to open the portal. +export function useCardPortal() { + 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 reset() { + setError("") + } + + return { redirecting, error, openPortal, reset } +} + +export type CardPortal = ReturnType