forked from coracle/caravel
Use plans from backend
This commit is contained in:
+5
-16
@@ -349,13 +349,8 @@ async fn list_tenants(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_plans(
|
async fn list_plans() -> Response {
|
||||||
State(state): State<AppState>,
|
ok(StatusCode::OK, Repo::list_plans())
|
||||||
headers: HeaderMap,
|
|
||||||
) -> std::result::Result<Response, ApiError> {
|
|
||||||
let _ = state.api.extract_auth_pubkey(&headers)?;
|
|
||||||
|
|
||||||
Ok(ok(StatusCode::OK, Repo::list_plans()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_identity(
|
async fn get_identity(
|
||||||
@@ -392,16 +387,10 @@ async fn get_identity(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_plan(
|
async fn get_plan(Path(id): Path<String>) -> Response {
|
||||||
State(state): State<AppState>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> std::result::Result<Response, ApiError> {
|
|
||||||
let _ = state.api.extract_auth_pubkey(&headers)?;
|
|
||||||
|
|
||||||
match Repo::list_plans().into_iter().find(|p| p.id == id) {
|
match Repo::list_plans().into_iter().find(|p| p.id == id) {
|
||||||
Some(plan) => Ok(ok(StatusCode::OK, plan)),
|
Some(plan) => ok(StatusCode::OK, plan),
|
||||||
None => Ok(err(StatusCode::NOT_FOUND, "not-found", "plan not found")),
|
None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { For } from "solid-js"
|
import { For } from "solid-js"
|
||||||
import { PLANS, type PlanId } from "@/lib/api"
|
import type { PlanId } from "@/lib/api"
|
||||||
|
import { plans } from "@/lib/state"
|
||||||
|
|
||||||
function CheckIcon() {
|
function CheckIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -19,6 +20,17 @@ function XIcon() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function priceLabel(sats: number) {
|
||||||
|
if (sats === 0) return "0"
|
||||||
|
if (sats >= 1000) return `${(sats / 1000).toLocaleString()}K`
|
||||||
|
return sats.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberLabel(members: number | null) {
|
||||||
|
if (members === null) return "Unlimited members"
|
||||||
|
return `Up to ${members} members`
|
||||||
|
}
|
||||||
|
|
||||||
type PricingTableProps = {
|
type PricingTableProps = {
|
||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
selectedPlan?: PlanId
|
selectedPlan?: PlanId
|
||||||
@@ -29,7 +41,7 @@ type PricingTableProps = {
|
|||||||
export default function PricingTable(props: PricingTableProps) {
|
export default function PricingTable(props: PricingTableProps) {
|
||||||
return (
|
return (
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
||||||
<For each={PLANS}>
|
<For each={plans()}>
|
||||||
{(plan) => {
|
{(plan) => {
|
||||||
const isPopular = plan.id === "basic"
|
const isPopular = plan.id === "basic"
|
||||||
const isSelected = () => props.selectable && props.selectedPlan === plan.id
|
const isSelected = () => props.selectable && props.selectedPlan === plan.id
|
||||||
@@ -41,14 +53,13 @@ export default function PricingTable(props: PricingTableProps) {
|
|||||||
POPULAR
|
POPULAR
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<h3 class="text-lg font-bold text-gray-900 mb-1">{plan.label}</h3>
|
<h3 class="text-lg font-bold text-gray-900 mb-1">{plan.name}</h3>
|
||||||
<p class="text-sm text-gray-400 mb-6">{plan.subtitle}</p>
|
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<span class="text-4xl font-extrabold text-gray-900">{plan.priceLabel}</span>
|
<span class="text-4xl font-extrabold text-gray-900">{priceLabel(plan.sats)}</span>
|
||||||
<span class="text-sm text-gray-400 ml-1">sats / mo</span>
|
<span class="text-sm text-gray-400 ml-1">sats / mo</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="mb-8 text-sm text-gray-600 space-y-3">
|
<ul class="mb-8 text-sm text-gray-600 space-y-3">
|
||||||
<li class="flex items-start gap-2"><CheckIcon />{plan.memberLabel}</li>
|
<li class="flex items-start gap-2"><CheckIcon />{memberLabel(plan.members)}</li>
|
||||||
<li class={`flex items-start gap-2 ${plan.blossom ? "" : "text-gray-300"}`}>
|
<li class={`flex items-start gap-2 ${plan.blossom ? "" : "text-gray-300"}`}>
|
||||||
{plan.blossom ? <CheckIcon /> : <XIcon />}
|
{plan.blossom ? <CheckIcon /> : <XIcon />}
|
||||||
Blossom storage
|
Blossom storage
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import PricingTable from "@/components/PricingTable"
|
|||||||
import ToggleButton from "@/components/ToggleButton"
|
import ToggleButton from "@/components/ToggleButton"
|
||||||
import ToggleField from "@/components/ToggleField"
|
import ToggleField from "@/components/ToggleField"
|
||||||
import { setToastMessage } from "@/components/Toast"
|
import { setToastMessage } from "@/components/Toast"
|
||||||
|
import { plans } from "@/lib/state"
|
||||||
|
|
||||||
function DetailSection(props: { title: string; children: any }) {
|
function DetailSection(props: { title: string; children: any }) {
|
||||||
return (
|
return (
|
||||||
@@ -61,13 +62,11 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
|||||||
|
|
||||||
let menuContainerRef: HTMLDivElement | undefined
|
let menuContainerRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
const memberLimitByPlan: Record<string, string> = {
|
const memberLimitLabel = () => {
|
||||||
free: "10",
|
const p = plans().find(p => p.id === r().plan)
|
||||||
basic: "100",
|
if (!p) return "?"
|
||||||
growth: "∞",
|
return p.members === null ? "∞" : String(p.members)
|
||||||
}
|
}
|
||||||
|
|
||||||
const memberLimitLabel = () => memberLimitByPlan[r().plan] ?? "?"
|
|
||||||
const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free"
|
const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free"
|
||||||
const showPlanActions = () => props.showPlanActions ?? true
|
const showPlanActions = () => props.showPlanActions ?? true
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createEffect, createSignal } from "solid-js"
|
import { createEffect, createMemo, createSignal, For } from "solid-js"
|
||||||
import type { Relay } from "@/lib/hooks"
|
import type { Relay } from "@/lib/hooks"
|
||||||
import { slugify } from "@/lib/slugify"
|
import { slugify } from "@/lib/slugify"
|
||||||
import { PLANS } from "@/lib/api"
|
|
||||||
import { setToastMessage } from "@/components/Toast"
|
import { setToastMessage } from "@/components/Toast"
|
||||||
|
import { plans } from "@/lib/state"
|
||||||
|
|
||||||
export type RelayFormValues = Pick<Relay, "info_name" | "subdomain" | "info_icon" | "info_description" | "plan">
|
export type RelayFormValues = Pick<Relay, "info_name" | "subdomain" | "info_icon" | "info_description" | "plan">
|
||||||
|
|
||||||
@@ -15,7 +15,8 @@ type RelayFormProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RelayForm(props: RelayFormProps) {
|
export default function RelayForm(props: RelayFormProps) {
|
||||||
const [plan, setPlan] = createSignal(props.initialValues?.plan ?? PLANS[0].id)
|
const defaultPlanId = createMemo(() => props.initialValues?.plan ?? plans()[0]?.id ?? "free")
|
||||||
|
const [plan, setPlan] = createSignal(defaultPlanId())
|
||||||
const [name, setName] = createSignal(props.initialValues?.info_name ?? "")
|
const [name, setName] = createSignal(props.initialValues?.info_name ?? "")
|
||||||
const [subdomain, setSubdomain] = createSignal(props.initialValues?.subdomain ?? "")
|
const [subdomain, setSubdomain] = createSignal(props.initialValues?.subdomain ?? "")
|
||||||
const [icon, setIcon] = createSignal(props.initialValues?.info_icon ?? "")
|
const [icon, setIcon] = createSignal(props.initialValues?.info_icon ?? "")
|
||||||
@@ -48,6 +49,8 @@ export default function RelayForm(props: RelayFormProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => setPlan(defaultPlanId()))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.syncSubdomainWithName) {
|
if (props.syncSubdomainWithName) {
|
||||||
setSubdomain(slugify(name()))
|
setSubdomain(slugify(name()))
|
||||||
@@ -98,23 +101,23 @@ export default function RelayForm(props: RelayFormProps) {
|
|||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-3">Plan</label>
|
<label class="block text-sm font-medium text-gray-700 mb-3">Plan</label>
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
{PLANS.map(p => (
|
<For each={plans()}>
|
||||||
<button
|
{(p) => (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setPlan(p.id)}
|
type="button"
|
||||||
class={`border-2 rounded-xl p-4 text-left transition-colors ${
|
onClick={() => setPlan(p.id)}
|
||||||
plan() === p.id
|
class={`border-2 rounded-xl p-4 text-left transition-colors ${plan() === p.id ? "border-blue-600 bg-blue-50" : "border-gray-200 hover:border-gray-300"}`}
|
||||||
? "border-blue-600 bg-blue-50"
|
>
|
||||||
: "border-gray-200 hover:border-gray-300"
|
<div class="font-bold text-gray-900">{p.name}</div>
|
||||||
}`}
|
<div class="text-sm text-gray-500 mt-1">
|
||||||
>
|
{p.sats === 0 ? "Free" : `${p.sats.toLocaleString()} sats/mo`}
|
||||||
<div class="font-bold text-gray-900">{p.label}</div>
|
</div>
|
||||||
<div class="text-sm text-gray-500 mt-1">
|
<div class="text-xs text-gray-500 mt-2">
|
||||||
{p.price === 0 ? "Free" : `${p.price.toLocaleString()} sats/mo`}
|
{p.members === null ? "Unlimited members" : `Up to ${p.members} members`}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 mt-2">{p.memberLabel}</div>
|
</button>
|
||||||
</button>
|
)}
|
||||||
))}
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
+22
-40
@@ -1,7 +1,11 @@
|
|||||||
import { account } from "@/lib/state"
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL
|
const API_URL = import.meta.env.VITE_API_URL
|
||||||
|
|
||||||
|
// Populated by state.ts after it initializes, breaking the circular dependency
|
||||||
|
let getAccount: () => unknown = () => undefined
|
||||||
|
export function registerAccountGetter(fn: () => unknown) {
|
||||||
|
getAccount = fn
|
||||||
|
}
|
||||||
|
|
||||||
type ApiOk<T> = {
|
type ApiOk<T> = {
|
||||||
data: T
|
data: T
|
||||||
code: string
|
code: string
|
||||||
@@ -27,42 +31,16 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Plan = Record<string, unknown>
|
export type Plan = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
sats: number
|
||||||
|
members: number | null
|
||||||
|
blossom: boolean
|
||||||
|
livekit: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export const PLANS = [
|
export type PlanId = string
|
||||||
{
|
|
||||||
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
|
|
||||||
|
|
||||||
export type PlanId = (typeof PLANS)[number]["id"]
|
|
||||||
|
|
||||||
export type Relay = {
|
export type Relay = {
|
||||||
id: string
|
id: string
|
||||||
@@ -145,7 +123,7 @@ export type Identity = {
|
|||||||
let authCache: AuthCache | undefined
|
let authCache: AuthCache | undefined
|
||||||
|
|
||||||
export async function makeAuth(): Promise<string | undefined> {
|
export async function makeAuth(): Promise<string | undefined> {
|
||||||
const current = account()
|
const current = getAccount() as { pubkey: string; signer: { signEvent: (e: unknown) => Promise<{ pubkey: string; sig: string; [k: string]: unknown }> } } | undefined
|
||||||
if (!current) return undefined
|
if (!current) return undefined
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -203,8 +181,12 @@ export async function callApi<TRequest = unknown, TResponse = unknown>(
|
|||||||
return payload.data
|
return payload.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listPlans() {
|
export async function listPlans(): Promise<Plan[]> {
|
||||||
return callApi<undefined, Plan[]>("GET", "/plans")
|
const url = new URL("/plans", API_URL).toString()
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) throw new ApiError(`Failed to load plans (${response.status})`, response.status)
|
||||||
|
const payload = await response.json() as { data: Plan[] }
|
||||||
|
return payload.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIdentity() {
|
export function getIdentity() {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { EventStore } from "applesauce-core"
|
|||||||
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
|
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
|
||||||
import { RelayPool } from "applesauce-relay"
|
import { RelayPool } from "applesauce-relay"
|
||||||
import { NostrConnectSigner } from "applesauce-signers"
|
import { NostrConnectSigner } from "applesauce-signers"
|
||||||
import { getIdentity } from "@/lib/api"
|
import { getIdentity, listPlans, registerAccountGetter, type Plan } from "@/lib/api"
|
||||||
|
|
||||||
export type UnsignedEvent = {
|
export type UnsignedEvent = {
|
||||||
kind: number
|
kind: number
|
||||||
@@ -44,6 +44,10 @@ registerCommonAccountTypes(accountManager)
|
|||||||
|
|
||||||
export const [account, setAccount] = createSignal<IAccount | undefined>()
|
export const [account, setAccount] = createSignal<IAccount | undefined>()
|
||||||
|
|
||||||
|
registerAccountGetter(account)
|
||||||
|
|
||||||
|
export const [plans] = createResource<Plan[]>(listPlans, { initialValue: [] })
|
||||||
|
|
||||||
export const [identity, { refetch: refetchIdentity, mutate: setIdentity }] = createResource(
|
export const [identity, { refetch: refetchIdentity, mutate: setIdentity }] = createResource(
|
||||||
() => account()?.pubkey,
|
() => account()?.pubkey,
|
||||||
pubkey => {
|
pubkey => {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- [ ] Show relay status on details page
|
||||||
|
- [ ] Show relay activity history on details page (add activity route and repo method to backend spec + implementation)
|
||||||
|
- [ ] Infra provisioning isn't happening. At least, the relay's status isn't being updated
|
||||||
|
- [ ] If relay is inactive, show "reactivate" instead of "inactivate" in the relay detal menu
|
||||||
|
- [ ] Invoices
|
||||||
|
- [ ] Stripe
|
||||||
Reference in New Issue
Block a user