Work on billing

This commit is contained in:
Jon Staab
2026-03-09 10:04:13 -07:00
parent 01d9d3bd05
commit 1ea087643b
8 changed files with 441 additions and 81 deletions
+108
View File
@@ -0,0 +1,108 @@
import { A } from "@solidjs/router"
import { For } from "solid-js"
import { RELAY_PLANS, type RelayPlanId } from "../lib/relayPlans"
function CheckIcon() {
return (
<svg class="w-4 h-4 text-blue-500 shrink-0 mt-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6L9 17l-5-5" />
</svg>
)
}
function XIcon() {
return (
<span class="w-4 h-4 shrink-0 mt-0.5 flex items-center justify-center">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</span>
)
}
type PricingTableProps = {
selectable?: boolean
selectedPlan?: RelayPlanId
onSelect?: (plan: RelayPlanId) => void
ctaHref?: string
compactOnMobile?: boolean
}
export default function PricingTable(props: PricingTableProps) {
return (
<div class={`grid items-start ${props.compactOnMobile ? "grid-cols-3 gap-2 sm:grid-cols-1 sm:gap-6 md:grid-cols-3" : "grid-cols-1 md:grid-cols-3 gap-6"}`}>
<For each={RELAY_PLANS}>
{(plan) => {
const isPopular = plan.id === "basic"
const isSelected = () => props.selectable && props.selectedPlan === plan.id
const card = (
<>
{isPopular && !props.selectable && (
<span class="absolute -top-3.5 left-1/2 -translate-x-1/2 bg-blue-600 text-white text-xs font-bold px-4 py-1 rounded-full tracking-wide">
POPULAR
</span>
)}
<h3 class={`font-bold text-gray-900 mb-1 ${props.compactOnMobile ? "text-sm sm:text-lg" : "text-lg"}`}>{plan.label}</h3>
<p class={`text-gray-400 mb-6 ${props.compactOnMobile ? "hidden sm:block text-sm" : "text-sm"}`}>{plan.subtitle}</p>
<div class={props.compactOnMobile ? "mb-3 sm:mb-8" : "mb-8"}>
<span class={`font-extrabold text-gray-900 ${props.compactOnMobile ? "text-xl sm:text-4xl" : "text-4xl"}`}>{plan.priceLabel}</span>
<span class={`text-gray-400 ml-1 ${props.compactOnMobile ? "text-[10px] sm:text-sm" : "text-sm"}`}>sats / mo</span>
</div>
<ul class={`mb-8 text-sm text-gray-600 ${props.compactOnMobile ? "hidden sm:block space-y-3" : "space-y-3"}`}>
<li class="flex items-start gap-2"><CheckIcon />{plan.memberLabel}</li>
<li class={`flex items-start gap-2 ${plan.blossom ? "" : "text-gray-300"}`}>
{plan.blossom ? <CheckIcon /> : <XIcon />}
Blossom storage
</li>
<li class={`flex items-start gap-2 ${plan.livekit ? "" : "text-gray-300"}`}>
{plan.livekit ? <CheckIcon /> : <XIcon />}
LiveKit video
</li>
</ul>
{props.compactOnMobile && props.selectable && (
<div class="sm:hidden mb-3 text-[10px] text-gray-500 space-y-1">
<div>{plan.memberLabel.replace(" members", "")}</div>
<div class="flex items-center gap-1">
<span class={plan.blossom ? "text-blue-600" : "text-gray-300"}>{plan.blossom ? "✓" : "✕"}</span>
Blossom
</div>
<div class="flex items-center gap-1">
<span class={plan.livekit ? "text-blue-600" : "text-gray-300"}>{plan.livekit ? "✓" : "✕"}</span>
LiveKit
</div>
</div>
)}
{!props.selectable && (
<A
href={props.ctaHref ?? "/relays/new"}
class={`block text-center rounded-xl font-semibold transition-colors ${props.compactOnMobile ? "py-1.5 px-2 text-[11px] sm:py-2.5 sm:px-4 sm:text-sm" : "py-2.5 px-4 text-sm"} ${isPopular ? "bg-blue-600 text-white hover:bg-blue-700" : "border border-gray-200 text-gray-700 hover:bg-gray-50"}`}
>
Get started
</A>
)}
</>
)
if (props.selectable) {
return (
<button
type="button"
onClick={() => props.onSelect?.(plan.id)}
class={`relative w-full bg-white rounded-2xl text-left transition-all ${props.compactOnMobile ? "p-3 sm:p-8" : "p-8"} ${isSelected() ? "border-2 border-blue-600 shadow-lg shadow-blue-100" : "border border-gray-200 hover:border-gray-300"}`}
>
{card}
</button>
)
}
return (
<div class={`relative bg-white rounded-2xl ${props.compactOnMobile ? "p-3 sm:p-8" : "p-8"} ${isPopular ? "border-2 border-blue-600 shadow-lg shadow-blue-100" : "border border-gray-200"}`}>
{card}
</div>
)
}}
</For>
</div>
)
}
+141 -22
View File
@@ -2,6 +2,9 @@ import { A } from "@solidjs/router"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import type { Relay } from "../lib/api"
import menuDotsIcon from "../assets/menu-dots-2.svg"
import Modal from "./Modal"
import PricingTable from "./PricingTable"
import { RELAY_PLAN_IDS, type RelayPlanId } from "../lib/relayPlans"
function Field(props: { label: string; children: any }) {
return (
@@ -53,7 +56,7 @@ function ToggleButton(props: {
function DetailSection(props: { title: string; children: any }) {
return (
<div>
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">{props.title}</h3>
<h3 class="text-sm font-semibold uppercase tracking-wider mb-6">{props.title}</h3>
<dl class="grid lg:grid-cols-2 gap-x-32 gap-y-4">
{props.children}
</dl>
@@ -64,7 +67,7 @@ function DetailSection(props: { title: string; children: any }) {
function MembershipSection(props: { title: string; children: any }) {
return (
<div>
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">{props.title}</h3>
<h3 class="text-sm font-semibold uppercase tracking-wider mb-6">{props.title}</h3>
<dl class="grid gap-x-6 gap-y-4 sm:grid-cols-2">
{props.children}
</dl>
@@ -86,12 +89,20 @@ type RelayDetailCardProps = {
onToggleMediaStorage?: () => void
onToggleLivekitSupport?: () => void
onTogglePushNotifications?: () => void
onUpdatePlan?: (plan: RelayPlanId) => Promise<void>
updatingPlan?: boolean
enforcePlanLimits?: boolean
showPlanActions?: boolean
}
export default function RelayDetailCard(props: RelayDetailCardProps) {
const r = () => props.relay
const cfg = () => r().config
const [menuOpen, setMenuOpen] = createSignal(false)
const [planModalOpen, setPlanModalOpen] = createSignal(false)
const [selectedPlan, setSelectedPlan] = createSignal<RelayPlanId>("free")
const [planError, setPlanError] = createSignal("")
const [submittingPlan, setSubmittingPlan] = createSignal(false)
let menuContainerRef: HTMLDivElement | undefined
const memberLimitByPlan: Record<string, string> = {
@@ -102,6 +113,32 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
const memberLimitLabel = () => memberLimitByPlan[r().plan] ?? "?"
const isTopTier = () => r().plan === "growth"
const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free"
const showPlanActions = () => props.showPlanActions ?? true
createEffect(() => {
if (!planModalOpen()) return
const current = RELAY_PLAN_IDS.find((id) => id === r().plan) ?? "free"
setSelectedPlan(current)
setPlanError("")
})
const canSubmitPlan = () => selectedPlan() !== r().plan
async function handlePlanContinue() {
if (!props.onUpdatePlan || submittingPlan() || !canSubmitPlan()) return
setPlanError("")
setSubmittingPlan(true)
try {
await props.onUpdatePlan(selectedPlan())
setPlanModalOpen(false)
} catch (e) {
setPlanError(e instanceof Error ? e.message : "Failed to update plan")
} finally {
setSubmittingPlan(false)
}
}
createEffect(() => {
if (!menuOpen()) return
@@ -231,14 +268,29 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</ToggleField>
<ToggleField label="Media storage">
<Show
when={r().plan !== "free"}
when={!planLimited()}
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>
<Show when={showPlanActions()} fallback={<ToggleButton enabled={cfg()?.blossom.enabled ?? false} onToggle={props.onToggleMediaStorage} />}>
<Show
when={props.onUpdatePlan}
fallback={
<A
href={props.editHref ?? "#"}
class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
>
Upgrade Plan
</A>
}
>
<button
type="button"
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"
onClick={() => setPlanModalOpen(true)}
>
Update Plan
</button>
</Show>
</Show>
}
>
<ToggleButton
@@ -249,14 +301,29 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</ToggleField>
<ToggleField label="LiveKit support">
<Show
when={r().plan !== "free"}
when={!planLimited()}
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>
<Show when={showPlanActions()} fallback={<ToggleButton enabled={cfg()?.livekit.enabled ?? false} onToggle={props.onToggleLivekitSupport} />}>
<Show
when={props.onUpdatePlan}
fallback={
<A
href={props.editHref ?? "#"}
class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
>
Upgrade Plan
</A>
}
>
<button
type="button"
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"
onClick={() => setPlanModalOpen(true)}
>
Update Plan
</button>
</Show>
</Show>
}
>
<ToggleButton
@@ -281,17 +348,69 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
<span class="font-mono text-xs break-all">{r().tenant}</span>
</Field>
</Show>
<Show when={!isTopTier() && props.editHref}>
<Show when={props.editHref && showPlanActions()}>
<Field label=" ">
<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"
<Show
when={props.onUpdatePlan}
fallback={
<Show
when={!isTopTier()}
fallback={<span />}
>
<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>
</Show>
}
>
Upgrade Plan
</A>
<button
type="button"
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"
onClick={() => setPlanModalOpen(true)}
>
Update Plan
</button>
</Show>
</Field>
</Show>
</MembershipSection>
<Modal
open={planModalOpen()}
onClose={() => setPlanModalOpen(false)}
wrapperClass="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4"
panelClass="w-full max-w-5xl max-h-[90vh] overflow-y-auto rounded-2xl bg-white"
>
<div class="p-6 sm:p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-2">Update plan</h2>
<p class="text-sm text-gray-500 mb-8">Choose the plan you want for this relay.</p>
<PricingTable
selectable
compactOnMobile
selectedPlan={selectedPlan()}
onSelect={setSelectedPlan}
/>
<Show when={planError()}>
<p class="mt-4 text-sm text-red-600">{planError()}</p>
</Show>
<div class="mt-8">
<button
type="button"
onClick={() => void handlePlanContinue()}
disabled={!canSubmitPlan() || submittingPlan() || !!props.updatingPlan}
class="w-full py-3 rounded-xl bg-blue-600 text-white font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submittingPlan() || props.updatingPlan ? "Updating..." : "Continue"}
</button>
</div>
</div>
</Modal>
</div>
)
}