forked from coracle/caravel
Work on billing
This commit is contained in:
@@ -9,6 +9,7 @@ use axum::{
|
||||
routing::{get, post, put},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::auth::verify_nip98;
|
||||
use crate::models::{NewTenant, Relay, RelayConfig};
|
||||
@@ -33,6 +34,7 @@ pub fn router(state: AppState) -> Router {
|
||||
"/tenant/relays/:id",
|
||||
get(get_tenant_relay).put(update_tenant_relay),
|
||||
)
|
||||
.route("/tenant/relays/:id/plan", put(update_tenant_relay_plan))
|
||||
.route(
|
||||
"/tenant/relays/:id/deactivate",
|
||||
post(deactivate_tenant_relay),
|
||||
@@ -401,6 +403,115 @@ async fn update_tenant_relay(
|
||||
(StatusCode::OK, Json(relay)).into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UpdateRelayPlanRequest {
|
||||
plan: String,
|
||||
}
|
||||
|
||||
async fn update_tenant_relay_plan(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
Path(id): Path<String>,
|
||||
Json(payload): Json<UpdateRelayPlanRequest>,
|
||||
) -> Response {
|
||||
let pubkey = match extract_auth_pubkey(&headers, &method, &uri) {
|
||||
Ok(pubkey) => pubkey,
|
||||
Err(_) => return unauthorized(),
|
||||
};
|
||||
|
||||
let plan = payload.plan.trim().to_lowercase();
|
||||
if !matches!(plan.as_str(), "free" | "basic" | "growth") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError {
|
||||
error: "invalid plan".into(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let existing = match state.repo.get_relay(&id).await {
|
||||
Ok(Some(relay)) => relay,
|
||||
Ok(None) => return not_found(),
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: "failed to load relay".into(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if existing.tenant != pubkey {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
let mut relay = Relay {
|
||||
plan,
|
||||
..existing
|
||||
};
|
||||
|
||||
if relay.plan == "free" {
|
||||
relay.config = Some(disable_paid_features(relay.config));
|
||||
}
|
||||
|
||||
if let Err(_) = state.repo.upsert_relay(&relay).await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: "failed to update relay plan".into(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(err) = state.provisioner.update_relay(&relay).await {
|
||||
tracing::error!(relay_id = relay.id, error = %err, "zooid patch failed");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: format!("failed to provision relay: {err}"),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let _ = state.repo.update_relay_status(&relay.id, "active").await;
|
||||
|
||||
(StatusCode::OK, Json(relay)).into_response()
|
||||
}
|
||||
|
||||
fn disable_paid_features(config: Option<RelayConfig>) -> RelayConfig {
|
||||
let mut cfg = config.unwrap_or_else(empty_relay_config);
|
||||
set_config_bool(&mut cfg.blossom, "enabled", false);
|
||||
set_config_bool(&mut cfg.livekit, "enabled", false);
|
||||
cfg
|
||||
}
|
||||
|
||||
fn empty_relay_config() -> RelayConfig {
|
||||
RelayConfig {
|
||||
policy: None,
|
||||
groups: None,
|
||||
management: None,
|
||||
blossom: None,
|
||||
livekit: None,
|
||||
push: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_config_bool(section: &mut Option<Value>, key: &str, enabled: bool) {
|
||||
let mut object = section
|
||||
.take()
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
object.insert(key.to_string(), Value::Bool(enabled));
|
||||
*section = Some(Value::Object(object));
|
||||
}
|
||||
|
||||
async fn deactivate_tenant_relay(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -192,6 +192,13 @@ export function updateTenantRelay(id: string, input: UpdateRelayInput) {
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTenantRelayPlan(id: string, plan: string) {
|
||||
return request<Relay>(`/tenant/relays/${id}/plan`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ plan }),
|
||||
})
|
||||
}
|
||||
|
||||
export function deactivateTenantRelay(id: string) {
|
||||
return request<Relay>(`/tenant/relays/${id}/deactivate`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,3 +1,45 @@
|
||||
export const RELAY_PLAN_IDS = ["free", "basic", "growth"] as const
|
||||
|
||||
export type RelayPlanId = (typeof RELAY_PLAN_IDS)[number]
|
||||
|
||||
export const RELAY_PLANS = [
|
||||
{
|
||||
id: "free",
|
||||
label: "Free",
|
||||
subtitle: "Get started, no commitment.",
|
||||
price: 0,
|
||||
priceLabel: "0",
|
||||
memberLabel: "Up to 10 members",
|
||||
blossom: false,
|
||||
livekit: false,
|
||||
},
|
||||
{
|
||||
id: "basic",
|
||||
label: "Basic",
|
||||
subtitle: "For growing communities.",
|
||||
price: 10_000,
|
||||
priceLabel: "10K",
|
||||
memberLabel: "Up to 100 members",
|
||||
blossom: true,
|
||||
livekit: true,
|
||||
},
|
||||
{
|
||||
id: "growth",
|
||||
label: "Growth",
|
||||
subtitle: "For large-scale communities.",
|
||||
price: 50_000,
|
||||
priceLabel: "50K",
|
||||
memberLabel: "Unlimited members",
|
||||
blossom: true,
|
||||
livekit: true,
|
||||
},
|
||||
] as const satisfies readonly {
|
||||
id: RelayPlanId
|
||||
label: string
|
||||
subtitle: string
|
||||
price: number
|
||||
priceLabel: string
|
||||
memberLabel: string
|
||||
blossom: boolean
|
||||
livekit: boolean
|
||||
}[]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { A } from "@solidjs/router"
|
||||
import PricingTable from "../components/PricingTable"
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
@@ -260,64 +261,7 @@ export default function Home() {
|
||||
Pay in sats. Upgrade or cancel any time.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 items-start">
|
||||
{/* Free */}
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-8">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-1">Free</h3>
|
||||
<p class="text-sm text-gray-400 mb-6">Get started, no commitment.</p>
|
||||
<div class="mb-8">
|
||||
<span class="text-4xl font-extrabold text-gray-900">0</span>
|
||||
<span class="text-sm text-gray-400 ml-1">sats / mo</span>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 text-sm text-gray-600">
|
||||
<li class="flex items-start gap-2"><CheckIcon />Up to 10 members</li>
|
||||
<li class="flex items-start gap-2 text-gray-300"><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>Blossom storage</li>
|
||||
<li class="flex items-start gap-2 text-gray-300"><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>LiveKit video</li>
|
||||
</ul>
|
||||
<A href="/relays/new" class="block text-center py-2.5 px-4 rounded-xl border border-gray-200 text-sm font-semibold text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
Get started
|
||||
</A>
|
||||
</div>
|
||||
|
||||
{/* Basic */}
|
||||
<div class="bg-white rounded-2xl border-2 border-blue-600 p-8 relative shadow-lg shadow-blue-100">
|
||||
<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="text-lg font-bold text-gray-900 mb-1">Basic</h3>
|
||||
<p class="text-sm text-gray-400 mb-6">For growing communities.</p>
|
||||
<div class="mb-8">
|
||||
<span class="text-4xl font-extrabold text-gray-900">10K</span>
|
||||
<span class="text-sm text-gray-400 ml-1">sats / mo</span>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 text-sm text-gray-600">
|
||||
<li class="flex items-start gap-2"><CheckIcon />Up to 100 members</li>
|
||||
<li class="flex items-start gap-2"><CheckIcon />Blossom storage</li>
|
||||
<li class="flex items-start gap-2"><CheckIcon />LiveKit video</li>
|
||||
</ul>
|
||||
<A href="/relays/new" class="block text-center py-2.5 px-4 rounded-xl bg-blue-600 text-white text-sm font-semibold hover:bg-blue-700 transition-colors">
|
||||
Get started
|
||||
</A>
|
||||
</div>
|
||||
|
||||
{/* Growth */}
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-8">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-1">Growth</h3>
|
||||
<p class="text-sm text-gray-400 mb-6">For large-scale communities.</p>
|
||||
<div class="mb-8">
|
||||
<span class="text-4xl font-extrabold text-gray-900">50K</span>
|
||||
<span class="text-sm text-gray-400 ml-1">sats / mo</span>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 text-sm text-gray-600">
|
||||
<li class="flex items-start gap-2"><CheckIcon />Unlimited members</li>
|
||||
<li class="flex items-start gap-2"><CheckIcon />Blossom storage</li>
|
||||
<li class="flex items-start gap-2"><CheckIcon />LiveKit video</li>
|
||||
</ul>
|
||||
<A href="/relays/new" class="block text-center py-2.5 px-4 rounded-xl border border-gray-200 text-sm font-semibold text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
Get started
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
<PricingTable ctaHref="/relays/new" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -171,6 +171,8 @@ export default function AdminRelayDetail() {
|
||||
onToggleMediaStorage={toggleMediaStorage}
|
||||
onToggleLivekitSupport={toggleLivekitSupport}
|
||||
onTogglePushNotifications={togglePushNotifications}
|
||||
enforcePlanLimits={false}
|
||||
showPlanActions={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createMemo, createResource, createSignal, Show } from "solid-js"
|
||||
import { deactivateTenantRelay, getRelayMemberCount, getTenantRelay, updateTenantRelay, type RelayConfig } from "../../lib/api"
|
||||
import { deactivateTenantRelay, getRelayMemberCount, getTenantRelay, updateTenantRelay, updateTenantRelayPlan, type RelayConfig } from "../../lib/api"
|
||||
import type { RelayPlanId } from "../../lib/relayPlans"
|
||||
import BackLink from "../../components/BackLink"
|
||||
import PageContainer from "../../components/PageContainer"
|
||||
import RelayDetailCard from "../../components/RelayDetailCard"
|
||||
@@ -144,6 +145,31 @@ export default function RelayDetail() {
|
||||
void updateFlags(nextConfig, config)
|
||||
}
|
||||
|
||||
async function handleUpdatePlan(plan: RelayPlanId) {
|
||||
const current = relay()
|
||||
if (!current) return
|
||||
|
||||
const previous = current
|
||||
setError("")
|
||||
|
||||
const nextConfig = withDefaults(current.config)
|
||||
if (plan === "free") {
|
||||
nextConfig.blossom = { enabled: false }
|
||||
nextConfig.livekit = { enabled: false }
|
||||
}
|
||||
|
||||
mutate({ ...current, plan, config: nextConfig })
|
||||
|
||||
try {
|
||||
await updateTenantRelayPlan(relayId(), plan)
|
||||
await refetch()
|
||||
} catch (e) {
|
||||
mutate(previous)
|
||||
setError(e instanceof Error ? e.message : "Failed to update relay plan")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<BackLink href="/relays" label="Relays" />
|
||||
@@ -172,6 +198,7 @@ export default function RelayDetail() {
|
||||
onToggleMediaStorage={toggleMediaStorage}
|
||||
onToggleLivekitSupport={toggleLivekitSupport}
|
||||
onTogglePushNotifications={togglePushNotifications}
|
||||
onUpdatePlan={handleUpdatePlan}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user