mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Show self-hosted and hosted Hub plans (#4213)
* feat(website): publish Hub pricing options Present self-hosted and hosted Hub choices together while reading the hosted offer from Hub's public catalog. * fix(website): require a valid hosted offer * fix(website): preserve Hub page on catalog failure
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
|
||||
const HUB_PLANS_URL =
|
||||
import.meta.env.VITE_HUB_PLANS_URL ?? "https://hub.paseo.sh/api/billing/plans";
|
||||
|
||||
export interface HubHostedOffer {
|
||||
name: string;
|
||||
billing: {
|
||||
model: "per_unit";
|
||||
unit: { key: string; label: string };
|
||||
};
|
||||
features: Array<{ key: string; label: string; tooltip: string | null }>;
|
||||
price: HubBillingPrice & { interval: "monthly" };
|
||||
}
|
||||
|
||||
interface HubBillingPrice {
|
||||
interval: "monthly" | "annual";
|
||||
intervalCount: number;
|
||||
unitAmount: number;
|
||||
currency: string;
|
||||
tooltip: string | null;
|
||||
}
|
||||
|
||||
interface HubBillingPlan extends Omit<HubHostedOffer, "price"> {
|
||||
slug: string;
|
||||
prices: HubBillingPrice[];
|
||||
}
|
||||
|
||||
export function parseHostedOfferResponse(value: unknown): HubHostedOffer {
|
||||
if (!isRecord(value) || !Array.isArray(value["plans"])) throw new Error("Invalid Hub plans");
|
||||
const hosted = value["plans"].map(parsePlan).find((plan) => plan.slug === "hosted");
|
||||
const price = hosted?.prices.find(isMonthlyPrice);
|
||||
if (hosted === undefined || price === undefined) throw new Error("Hosted Hub is unavailable");
|
||||
return {
|
||||
name: hosted.name,
|
||||
billing: hosted.billing,
|
||||
features: hosted.features,
|
||||
price,
|
||||
};
|
||||
}
|
||||
|
||||
export const getHostedOffer = createServerFn({ method: "GET" }).handler(async () => {
|
||||
const response = await fetch(HUB_PLANS_URL, {
|
||||
headers: { accept: "application/json" },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Hub plans request failed (${response.status})`);
|
||||
return parseHostedOfferResponse(await response.json());
|
||||
});
|
||||
|
||||
function parsePlan(value: unknown): HubBillingPlan {
|
||||
if (!isRecord(value) || typeof value["slug"] !== "string" || typeof value["name"] !== "string")
|
||||
throw new Error("Invalid Hub plan");
|
||||
const billing = parseBilling(value["billing"]);
|
||||
if (!Array.isArray(value["features"]) || !Array.isArray(value["prices"]))
|
||||
throw new Error("Invalid Hub plan presentation");
|
||||
return {
|
||||
slug: value["slug"],
|
||||
name: value["name"],
|
||||
billing,
|
||||
features: value["features"].map(parseFeature),
|
||||
prices: value["prices"].map(parsePrice),
|
||||
};
|
||||
}
|
||||
|
||||
function parseBilling(value: unknown): HubHostedOffer["billing"] {
|
||||
if (!isRecord(value) || value["model"] !== "per_unit" || !isRecord(value["unit"]))
|
||||
throw new Error("Invalid Hub plan billing model");
|
||||
const unit = value["unit"];
|
||||
if (typeof unit["key"] !== "string" || typeof unit["label"] !== "string")
|
||||
throw new Error("Invalid Hub plan billing unit");
|
||||
return {
|
||||
model: "per_unit",
|
||||
unit: { key: unit["key"], label: unit["label"] },
|
||||
};
|
||||
}
|
||||
|
||||
function parseFeature(value: unknown): HubHostedOffer["features"][number] {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value["key"] !== "string" ||
|
||||
typeof value["label"] !== "string" ||
|
||||
!isNullableString(value["tooltip"])
|
||||
)
|
||||
throw new Error("Invalid Hub plan feature");
|
||||
return { key: value["key"], label: value["label"], tooltip: value["tooltip"] };
|
||||
}
|
||||
|
||||
function parsePrice(value: unknown): HubBillingPrice {
|
||||
const intervalCount = isRecord(value) ? value["intervalCount"] : undefined;
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
(value["interval"] !== "monthly" && value["interval"] !== "annual") ||
|
||||
typeof intervalCount !== "number" ||
|
||||
!Number.isInteger(intervalCount) ||
|
||||
intervalCount < 1 ||
|
||||
typeof value["unitAmount"] !== "number" ||
|
||||
typeof value["currency"] !== "string" ||
|
||||
!isNullableString(value["tooltip"])
|
||||
)
|
||||
throw new Error("Invalid Hub plan price");
|
||||
return {
|
||||
interval: value["interval"],
|
||||
intervalCount,
|
||||
unitAmount: value["unitAmount"],
|
||||
currency: value["currency"],
|
||||
tooltip: value["tooltip"],
|
||||
};
|
||||
}
|
||||
|
||||
function isMonthlyPrice(
|
||||
price: HubBillingPrice,
|
||||
): price is HubBillingPrice & { interval: "monthly" } {
|
||||
return price.interval === "monthly";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === "string";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Check, CircleHelp } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
ClaudeCodeIcon,
|
||||
@@ -11,6 +12,7 @@ import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons";
|
||||
import { AGENT_PAGES } from "~/data/agent-pages";
|
||||
import { FAQItem } from "~/components/faq-item";
|
||||
import { SiteShell } from "~/components/site-shell";
|
||||
import { getHostedOffer, type HubHostedOffer } from "~/hub-plans";
|
||||
import { pageMeta } from "~/meta";
|
||||
|
||||
export const Route = createFileRoute("/hub")({
|
||||
@@ -20,65 +22,199 @@ export const Route = createFileRoute("/hub")({
|
||||
"Run Paseo Hub yourself and start agents on your own machines from GitHub, Slack, and Discord.",
|
||||
"/hub",
|
||||
),
|
||||
loader: async () => {
|
||||
try {
|
||||
return { hosted: await getHostedOffer() };
|
||||
} catch {
|
||||
return { hosted: null };
|
||||
}
|
||||
},
|
||||
component: Hub,
|
||||
});
|
||||
|
||||
const DISCORD_INVITE_URL = "https://discord.gg/jz8T2uahpH";
|
||||
const HOSTED_HUB_URL = "https://hub.paseo.sh";
|
||||
|
||||
const LINK_CLASS = "underline hover:text-white/80";
|
||||
|
||||
function Hub() {
|
||||
const { hosted } = Route.useLoaderData();
|
||||
return (
|
||||
<SiteShell width="default">
|
||||
<h1 className="text-3xl font-medium tracking-tight mb-4">Paseo Hub</h1>
|
||||
<p className="text-lg text-white/70 leading-relaxed max-w-2xl">
|
||||
An optional service that sits above your daemons and gives them extra capabilities.
|
||||
</p>
|
||||
<p className="text-white/40 text-sm mt-3">Self-host now. Hosted version coming later.</p>
|
||||
<p className="text-white/40 text-sm mt-3">Run it yourself or use the hosted service.</p>
|
||||
|
||||
<div className="space-y-20 mt-16">
|
||||
<Triggers />
|
||||
<Agents />
|
||||
<Shape />
|
||||
<QuickStart />
|
||||
<Access />
|
||||
<Pricing hosted={hosted} />
|
||||
<FaqSection />
|
||||
</div>
|
||||
</SiteShell>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickStart() {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-xl font-medium">Get started</h2>
|
||||
<p className="text-white/70 leading-relaxed max-w-2xl">
|
||||
Run one command, then finish setup in the browser.
|
||||
</p>
|
||||
<pre className="w-fit max-w-full overflow-x-auto rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4 font-mono text-sm text-white/90">
|
||||
<code>npx @getpaseo/hub</code>
|
||||
</pre>
|
||||
<div className="flex flex-wrap items-center gap-4 pt-2">
|
||||
interface PlanFeature {
|
||||
key: string;
|
||||
label: string;
|
||||
tooltip: string | null;
|
||||
}
|
||||
|
||||
const SELF_HOSTED_FEATURES: readonly PlanFeature[] = [
|
||||
{
|
||||
key: "operate-hub",
|
||||
label: "You operate Hub",
|
||||
tooltip: null,
|
||||
},
|
||||
{
|
||||
key: "own-apps",
|
||||
label: "Provider apps you create and control",
|
||||
tooltip: null,
|
||||
},
|
||||
{
|
||||
key: "open-source",
|
||||
label: "Open source",
|
||||
tooltip: null,
|
||||
},
|
||||
];
|
||||
function Pricing({ hosted }: { hosted: HubHostedOffer | null }) {
|
||||
if (hosted === null) {
|
||||
return (
|
||||
<section className="space-y-6" aria-labelledby="pricing-heading">
|
||||
<div className="space-y-2">
|
||||
<h2 id="pricing-heading" className="text-xl font-medium">
|
||||
Choose how to run Hub
|
||||
</h2>
|
||||
<p className="max-w-2xl leading-relaxed text-white/70">
|
||||
The plan comparison is temporarily unavailable.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/docs/hub/quickstart"
|
||||
className="rounded-md bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 transition-colors"
|
||||
className="inline-block rounded-md border border-white/15 px-4 py-2 text-sm font-medium text-white/80 transition-colors hover:border-white/25 hover:text-white"
|
||||
>
|
||||
Read the quickstart
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/getpaseo/hub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-md border border-white/15 px-4 py-2 text-sm font-medium text-white/80 hover:border-white/25 hover:text-white transition-colors"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
View the source
|
||||
Self-host Hub
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6" aria-labelledby="pricing-heading">
|
||||
<div className="space-y-2">
|
||||
<h2 id="pricing-heading" className="text-xl font-medium">
|
||||
Choose how to run Hub
|
||||
</h2>
|
||||
<p className="max-w-2xl leading-relaxed text-white/70">
|
||||
Both options use Paseo daemons to run agents on your machines.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<PlanCard
|
||||
name="Self-hosted"
|
||||
price="Free"
|
||||
priceQualifier="/ open source"
|
||||
priceTooltip={null}
|
||||
features={SELF_HOSTED_FEATURES}
|
||||
actionHref="/docs/hub/quickstart"
|
||||
actionLabel="Self-host Hub"
|
||||
/>
|
||||
<PlanCard
|
||||
name={hosted.name}
|
||||
price={formatPrice(hosted.price)}
|
||||
priceQualifier={`per ${hosted.billing.unit.label} / ${formatBillingPeriod(hosted.price)}`}
|
||||
priceTooltip={hosted.price.tooltip}
|
||||
features={hosted.features}
|
||||
actionHref={HOSTED_HUB_URL}
|
||||
actionLabel="Start free trial"
|
||||
featured
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
name,
|
||||
price,
|
||||
priceQualifier,
|
||||
priceTooltip,
|
||||
features,
|
||||
actionHref,
|
||||
actionLabel,
|
||||
featured = false,
|
||||
}: {
|
||||
name: string;
|
||||
price: string;
|
||||
priceQualifier: string;
|
||||
priceTooltip: string | null;
|
||||
features: readonly PlanFeature[];
|
||||
actionHref: string;
|
||||
actionLabel: string;
|
||||
featured?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<article
|
||||
className={`flex flex-col rounded-2xl border p-6 ${featured ? "border-white/25 bg-white/[0.06]" : "border-white/10 bg-white/[0.02]"}`}
|
||||
>
|
||||
<h3 className="text-xl font-medium">{name}</h3>
|
||||
<div className="mt-6 flex items-end gap-2">
|
||||
<span className="text-4xl font-medium tracking-tight">{price}</span>
|
||||
<span className="mb-1 inline-flex items-center gap-1 text-sm text-white/45">
|
||||
{priceQualifier}
|
||||
{priceTooltip !== null && <InfoTip text={priceTooltip} />}
|
||||
</span>
|
||||
</div>
|
||||
{features.length > 0 && (
|
||||
<ul className="mt-6 grid flex-1 content-start gap-3 text-sm text-white/70">
|
||||
{features.map((feature) => (
|
||||
<li key={feature.key} className="flex items-start gap-2.5">
|
||||
<Check aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-emerald-300" />
|
||||
<span>{feature.label}</span>
|
||||
{feature.tooltip !== null && <InfoTip text={feature.tooltip} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<a
|
||||
href={actionHref}
|
||||
className={`mt-8 rounded-md px-4 py-2 text-center text-sm font-medium transition-colors ${featured ? "bg-white text-black hover:bg-white/90" : "border border-white/15 text-white/80 hover:border-white/25 hover:text-white"}`}
|
||||
>
|
||||
{actionLabel}
|
||||
</a>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoTip({ text }: { text: string }) {
|
||||
return (
|
||||
<span className="group relative inline-flex shrink-0" tabIndex={0}>
|
||||
<CircleHelp aria-label={text} className="size-3.5 text-white/35" />
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden w-56 -translate-x-1/2 rounded-md border border-white/10 bg-[#17201f] px-3 py-2 text-xs leading-relaxed text-white/75 shadow-xl group-hover:block group-focus:block"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPrice(price: HubHostedOffer["price"]): string {
|
||||
return new Intl.NumberFormat("en", {
|
||||
style: "currency",
|
||||
currency: price.currency.toUpperCase(),
|
||||
minimumFractionDigits: 0,
|
||||
}).format(price.unitAmount / 100);
|
||||
}
|
||||
|
||||
function formatBillingPeriod(price: HubHostedOffer["price"]): string {
|
||||
return price.intervalCount === 1 ? "month" : `${price.intervalCount} months`;
|
||||
}
|
||||
|
||||
const TRIGGER_SURFACES = [
|
||||
{ name: "GitHub", icon: <GitHubIcon className="h-6 w-6" />, body: "Issues, PRs, and reviews." },
|
||||
{ name: "Slack", icon: <SlackIcon className="h-6 w-6" />, body: "Channels and threads." },
|
||||
@@ -386,29 +522,6 @@ function MachineIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function Access() {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-xl font-medium">Hosted Hub</h2>
|
||||
<p className="text-white/70 leading-relaxed max-w-2xl">
|
||||
The hosted version is not generally available yet. Join the Paseo Discord and watch the
|
||||
<span className="font-mono text-white/80"> #paseo-hub</span> channel for the release
|
||||
announcement.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<a
|
||||
href={DISCORD_INVITE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-md bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 transition-colors"
|
||||
>
|
||||
Join the Discord
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqSection() {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
@@ -445,9 +558,8 @@ function FaqSection() {
|
||||
Node.js and a running Paseo daemon. Hub guides you through connecting the provider apps
|
||||
you want.
|
||||
</FAQItem>
|
||||
<FAQItem question="Will there be a hosted version?">
|
||||
Yes. Join the Paseo Discord and watch the <code>#paseo-hub</code> channel. The hosted
|
||||
release will be announced there.
|
||||
<FAQItem question="Is there a hosted version?">
|
||||
Yes. The hosted service runs the same Hub software you can self-host.
|
||||
</FAQItem>
|
||||
<FAQItem question="What else is planned?">
|
||||
<p>Not built yet, listed so you know where this is going.</p>
|
||||
@@ -470,17 +582,11 @@ function FaqSection() {
|
||||
triggers, and it's why access to the beta stays small and trusted.
|
||||
</FAQItem>
|
||||
<FAQItem question="How do I get access?">
|
||||
You can self-host today. For the hosted version, join the{" "}
|
||||
<a
|
||||
href={DISCORD_INVITE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
Paseo Discord
|
||||
You can self-host today or{" "}
|
||||
<a href={HOSTED_HUB_URL} className={LINK_CLASS}>
|
||||
sign in to Hosted Hub
|
||||
</a>{" "}
|
||||
and wait for the release announcement in <code>#paseo-hub</code>. You do not need to
|
||||
message anyone directly.
|
||||
to start a free trial.
|
||||
</FAQItem>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -8,8 +8,8 @@ category: Hub
|
||||
|
||||
# Hosted Hub
|
||||
|
||||
[Paseo Hub](https://hub.paseo.sh) is the managed service. It is not generally available yet: existing accounts can sign in, but new account registration is closed.
|
||||
[Paseo Hub](https://hub.paseo.sh) is the managed service. Sign in or create an account to start a free trial.
|
||||
|
||||
To start now, [run Hub yourself](/docs/hub/self-hosting). Projects, configuration, triggers, daemons, and activity use the same model in both forms. The managed service owns its GitHub App, Slack app, and Discord application; a self-hosted Hub uses apps you create and control.
|
||||
|
||||
For the hosted release, [join the Paseo Discord](https://discord.gg/jz8T2uahpH) and watch the `#paseo-hub` channel. The release will be announced there; you do not need to message anyone directly.
|
||||
Pricing and included features are also shown on the [Hub page](/hub).
|
||||
|
||||
@@ -56,4 +56,4 @@ If a workflow accepts requests from GitHub, Slack, Discord, or the API, read [Hu
|
||||
|
||||
Start on your machine with the embedded database, then add PostgreSQL or a public deployment only when you need them. [Self-hosting](/docs/hub/self-hosting) covers each step.
|
||||
|
||||
[Hosted Hub](/docs/hub/hosted) uses the same projects, workflows, daemons, and activity model. New account registration is currently closed.
|
||||
[Hosted Hub](/docs/hub/hosted) uses the same projects, workflows, daemons, and activity model. [Sign in to start a free trial](https://hub.paseo.sh).
|
||||
|
||||
Reference in New Issue
Block a user