diff --git a/packages/website/src/hub-plans.ts b/packages/website/src/hub-plans.ts new file mode 100644 index 0000000000..27521b2338 --- /dev/null +++ b/packages/website/src/hub-plans.ts @@ -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 { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} diff --git a/packages/website/src/routes/hub.tsx b/packages/website/src/routes/hub.tsx index e2d379cc61..ffc61d8d55 100644 --- a/packages/website/src/routes/hub.tsx +++ b/packages/website/src/routes/hub.tsx @@ -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 (

Paseo Hub

An optional service that sits above your daemons and gives them extra capabilities.

-

Self-host now. Hosted version coming later.

+

Run it yourself or use the hosted service.

- - +
); } -function QuickStart() { - return ( -
-

Get started

-

- Run one command, then finish setup in the browser. -

-
-        npx @getpaseo/hub
-      
-
+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 ( +
+
+

+ Choose how to run Hub +

+

+ The plan comparison is temporarily unavailable. +

+
- Read the quickstart - - - - View the source + Self-host Hub +
+ ); + } + + return ( +
+
+

+ Choose how to run Hub +

+

+ Both options use Paseo daemons to run agents on your machines. +

+
+
+ +
); } +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 ( +
+

{name}

+
+ {price} + + {priceQualifier} + {priceTooltip !== null && } + +
+ {features.length > 0 && ( +
    + {features.map((feature) => ( +
  • +
  • + ))} +
+ )} + + {actionLabel} + +
+ ); +} + +function InfoTip({ text }: { text: string }) { + return ( + + + + {text} + + + ); +} + +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: , body: "Issues, PRs, and reviews." }, { name: "Slack", icon: , body: "Channels and threads." }, @@ -386,29 +522,6 @@ function MachineIcon() { ); } -function Access() { - return ( -
-

Hosted Hub

-

- The hosted version is not generally available yet. Join the Paseo Discord and watch the - #paseo-hub channel for the release - announcement. -

- -
- ); -} - function FaqSection() { return (
@@ -445,9 +558,8 @@ function FaqSection() { Node.js and a running Paseo daemon. Hub guides you through connecting the provider apps you want. - - Yes. Join the Paseo Discord and watch the #paseo-hub channel. The hosted - release will be announced there. + + Yes. The hosted service runs the same Hub software you can self-host.

Not built yet, listed so you know where this is going.

@@ -470,17 +582,11 @@ function FaqSection() { triggers, and it's why access to the beta stays small and trusted.
- You can self-host today. For the hosted version, join the{" "} - - Paseo Discord + You can self-host today or{" "} + + sign in to Hosted Hub {" "} - and wait for the release announcement in #paseo-hub. You do not need to - message anyone directly. + to start a free trial.
diff --git a/public-docs/hub/hosted.md b/public-docs/hub/hosted.md index 73706a4db3..dfe37d1582 100644 --- a/public-docs/hub/hosted.md +++ b/public-docs/hub/hosted.md @@ -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). diff --git a/public-docs/hub/index.md b/public-docs/hub/index.md index 128555cfc5..f7bc1a6ae0 100644 --- a/public-docs/hub/index.md +++ b/public-docs/hub/index.md @@ -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).