From d4bd3a23f9f66247ca37a2b6a8dc4b34903c4b86 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 25 Oct 2026 15:50:32 -0700 Subject: [PATCH 1/2] Add /pricing page, nav/footer links, and plans-and-limits docs Three tiers (Intro, Pro, Enterprise) in the site's glass aesthetic with the canonical device/agent/human/session-history limits. Beta pricing (strikethrough list price, beta price, lock-in note) is driven by one constant, BETA_PRICING_ACTIVE. Intro/Pro CTAs go through Google sign-in to /onboarding/plan?plan=; Enterprise links to sales. Co-Authored-By: Claude Fable 5.1 --- web/app/pricing/page.tsx | 191 ++++++++++++ web/app/pricing/pricing.module.css | 398 ++++++++++++++++++++++++++ web/app/sitemap.ts | 6 + web/components/SiteFooter.tsx | 1 + web/components/SiteNav.tsx | 10 + web/content/docs/plans-and-limits.mdx | 72 +++++ web/lib/docs-nav.ts | 1 + web/lib/pricing.ts | 108 +++++++ web/lib/test/pricing-links.test.tsx | 21 ++ web/lib/test/pricing-page.test.tsx | 127 ++++++++ 10 files changed, 935 insertions(+) create mode 100644 web/app/pricing/page.tsx create mode 100644 web/app/pricing/pricing.module.css create mode 100644 web/content/docs/plans-and-limits.mdx create mode 100644 web/lib/pricing.ts create mode 100644 web/lib/test/pricing-links.test.tsx create mode 100644 web/lib/test/pricing-page.test.tsx diff --git a/web/app/pricing/page.tsx b/web/app/pricing/page.tsx new file mode 100644 index 00000000..b3101015 --- /dev/null +++ b/web/app/pricing/page.tsx @@ -0,0 +1,191 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { ArrowRight, Bot, History, Monitor, Users } from 'lucide-react'; + +import { FadeIn } from '../../components/FadeIn'; +import { SiteFooter } from '../../components/SiteFooter'; +import { SiteNav } from '../../components/SiteNav'; +import { defaultOgImage } from '../../lib/og-meta'; +import { + BETA_PRICING_ACTIVE, + BETA_PRICING_NOTE, + displayedPrice, + limitLines, + pricingTiers, + showsBetaPrice, + type PricingTier, +} from '../../lib/pricing'; +import { absoluteUrl, SITE_NAME } from '../../lib/site'; +import s from './pricing.module.css'; + +const pricingDescription = + 'Agent Relay plans: one flat monthly price per organization for devices, agents, humans, and session history. Intro, Pro, and Enterprise.'; + +export const metadata: Metadata = { + title: 'Pricing', + description: pricingDescription, + alternates: { + canonical: absoluteUrl('/pricing'), + }, + openGraph: { + siteName: SITE_NAME, + title: 'Pricing | Agent Relay', + description: pricingDescription, + url: absoluteUrl('/pricing'), + type: 'website', + images: [defaultOgImage()], + }, + twitter: { + card: 'summary_large_image', + title: 'Pricing | Agent Relay', + description: pricingDescription, + images: [defaultOgImage().url], + }, +}; + +const limitIcons = [Monitor, Bot, Users, History] as const; + +const faq = [ + { + question: 'What counts as a device?', + answer: + 'A device is a node connected to your organization: a laptop, a CI runner, or a cloud VM running agent relay. One device can host many agents. Agents and humans are counted separately from devices.', + }, + { + question: 'What happens when I reach a limit?', + answer: + 'The next device, agent, or human past your plan’s cap is refused with a clear message. Nothing already connected is disconnected. The desktop app tells you which limit you reached and links to upgrade.', + }, + { + question: 'Can I change plans?', + answer: + 'Yes. Upgrade from Intro to Pro, or cancel, from your organization’s billing settings. Plans are monthly and billed per organization, not per seat. Enterprise is set up with our team.', + }, + { + question: 'How do limits apply to existing customers?', + answer: + 'Existing customers keep their current access. Organizations created before plans launched are not held to the Intro or Pro caps and are not asked to pick a plan.', + }, +]; + +function Price({ tier }: { tier: PricingTier }) { + const price = displayedPrice(tier); + + if (price === null) { + return ( +
+ Custom + Invoiced annually or monthly +
+ ); + } + + return ( +
+
+ {showsBetaPrice(tier) && ( + + List price ${tier.listPrice} + + )} + + {showsBetaPrice(tier) ? 'Beta price ' : 'Price '} + ${price} + /mo + +
+ per organization, billed monthly +
+ ); +} + +function TierCard({ tier, index }: { tier: PricingTier; index: number }) { + const lines = limitLines(tier.limits); + const isSales = tier.key === 'enterprise'; + + return ( + +
+

{tier.name}

+ {tier.highlighted && Most popular} +
+

{tier.tagline}

+ + + +
    + {lines.map((line, lineIndex) => { + const Icon = limitIcons[lineIndex]; + return ( +
  • +
  • + ); + })} +
+ +
+ + {tier.cta.label} + + {showsBetaPrice(tier) &&

{BETA_PRICING_NOTE}

} + {isSales &&

Assigned by our team. Never self-serve.

} +
+
+ ); +} + +export default function PricingPage() { + return ( +
+ + +
+
+ +

Pricing

+

One flat price per organization.

+

+ No seats and no metering. Pick the size of agent team you run, connect your devices, and invite the + humans who work alongside them. +

+ {BETA_PRICING_ACTIVE && Beta pricing is on} +
+
+ +
+ {pricingTiers.map((tier, index) => ( + + ))} +
+ +
+ +

+ Questions +

+
+
+ {faq.map((item, index) => ( + +
{item.question}
+
{item.answer}
+
+ ))} +
+

+ Full detail on each plan is in the{' '} + + plans and limits + {' '} + docs. +

+
+
+ + +
+ ); +} diff --git a/web/app/pricing/pricing.module.css b/web/app/pricing/pricing.module.css new file mode 100644 index 00000000..cf2b4e71 --- /dev/null +++ b/web/app/pricing/pricing.module.css @@ -0,0 +1,398 @@ +.page { + min-height: 100svh; + overflow-x: clip; + background: var(--bg); + color: var(--fg); +} + +.main { + position: relative; + isolation: isolate; +} + +/* Soft primary glow behind the cards so the glass has something to blur. */ +.main::before { + content: ''; + position: absolute; + inset: 0 0 auto; + z-index: -1; + height: 920px; + background: + radial-gradient(60% 50% at 50% 18%, color-mix(in srgb, var(--primary) 22%, transparent) 0%, transparent 70%), + var(--hero-gradient); + pointer-events: none; +} + +.hero { + padding: 96px 24px 40px; +} + +.heroInner { + max-width: 760px; + margin: 0 auto; + text-align: center; +} + +.eyebrow { + margin: 0; + font-family: var(--font-geist-mono), monospace; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--primary); +} + +.title { + margin: 18px auto 0; + max-width: 14ch; + font-family: var(--font-heading), sans-serif; + font-size: clamp(2.6rem, 6vw, 4.5rem); + font-weight: 520; + letter-spacing: -0.05em; + line-height: 1.02; +} + +.lead { + max-width: 56ch; + margin: 22px auto 0; + color: var(--fg-muted); + font-size: clamp(1rem, 1.3vw, 1.15rem); + line-height: 1.7; +} + +.betaBadge { + display: inline-flex; + align-items: center; + gap: 8px; + margin-top: 26px; + padding: 7px 14px; + border: 1px solid color-mix(in srgb, var(--primary) 36%, transparent); + border-radius: var(--r-sm); + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.betaBadge::before { + content: ''; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--status-green); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--status-green) 28%, transparent); +} + +.tiers { + max-width: 1120px; + margin: 0 auto; + padding: 40px 24px 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; + align-items: stretch; +} + +.tier { + position: relative; + display: flex; + flex-direction: column; + padding: 30px 28px 28px; + border: 1px solid var(--card-border); + border-radius: var(--r-lg); + background: var(--card-bg); + box-shadow: + inset 0 1px 0 color-mix(in srgb, #ffffff 10%, transparent), + 0 18px 48px color-mix(in srgb, var(--fg) 8%, transparent); + -webkit-backdrop-filter: blur(12px) saturate(125%); + backdrop-filter: blur(12px) saturate(125%); + transition: + transform 180ms var(--ease-out-quint), + border-color 180ms ease, + box-shadow 180ms ease; +} + +@media (hover: hover) and (pointer: fine) { + .tier:hover { + transform: translateY(-3px); + border-color: var(--card-hover-border); + } +} + +.tierHighlighted { + border-color: color-mix(in srgb, var(--primary) 55%, transparent); + box-shadow: + inset 0 1px 0 color-mix(in srgb, #ffffff 12%, transparent), + 0 0 0 1px color-mix(in srgb, var(--primary) 22%, transparent), + 0 24px 64px color-mix(in srgb, var(--primary) 18%, transparent); +} + +@media (hover: hover) and (pointer: fine) { + .tierHighlighted:hover { + border-color: color-mix(in srgb, var(--primary) 75%, transparent); + } +} + +.tierHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.tierName { + margin: 0; + font-family: var(--font-heading), sans-serif; + font-size: 1.45rem; + font-weight: 560; + letter-spacing: -0.03em; +} + +.tierFlag { + padding: 4px 10px; + border-radius: var(--r-xs); + background: var(--primary-bg); + color: var(--primary-fg); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +.tierTagline { + min-height: 3.2em; + margin: 10px 0 0; + color: var(--fg-muted); + font-size: 0.92rem; + line-height: 1.6; +} + +.priceBlock { + margin-top: 26px; + padding-top: 22px; + border-top: 1px solid var(--line); + display: flex; + flex-direction: column; + gap: 6px; +} + +.priceRow { + display: flex; + align-items: baseline; + gap: 12px; + flex-wrap: wrap; +} + +.price, +.priceCustom { + font-family: var(--font-heading), sans-serif; + font-size: 2.75rem; + font-weight: 560; + letter-spacing: -0.05em; + line-height: 1; +} + +.per { + margin-left: 2px; + color: var(--fg-muted); + font-family: var(--font-geist-sans), sans-serif; + font-size: 1rem; + font-weight: 500; + letter-spacing: -0.01em; +} + +.listPrice { + color: var(--fg-faint); + font-family: var(--font-heading), sans-serif; + font-size: 1.5rem; + font-weight: 500; + letter-spacing: -0.04em; + text-decoration: line-through; + text-decoration-thickness: 2px; + text-decoration-color: var(--accent-warm); +} + +.priceMeta { + color: var(--fg-faint); + font-size: 0.8rem; +} + +.limits { + margin: 26px 0 0; + padding: 0; + list-style: none; + display: grid; + gap: 12px; +} + +.limits li { + display: flex; + align-items: center; + gap: 12px; + font-size: 0.95rem; + line-height: 1.4; +} + +.limits svg { + width: 18px; + height: 18px; + flex: 0 0 auto; + color: var(--primary); + stroke-width: 1.7; +} + +.tierActions { + margin-top: auto; + padding-top: 28px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.ctaPrimary { + composes: btn btn-primary from global; + width: 100%; +} + +.ctaSecondary { + composes: btn btn-secondary from global; + width: 100%; +} + +.ctaPrimary svg, +.ctaSecondary svg { + width: 16px; + height: 16px; + stroke-width: 1.8; + transition: transform 180ms var(--ease-out-quint); +} + +@media (hover: hover) and (pointer: fine) { + .ctaPrimary:hover svg, + .ctaSecondary:hover svg { + transform: translateX(3px); + } +} + +.betaNote { + margin: 0; + color: var(--fg-muted); + font-size: 0.78rem; + line-height: 1.5; + text-align: center; +} + +.faq { + max-width: 760px; + margin: 0 auto; + padding: 104px 24px 96px; +} + +.faqTitle { + margin: 0; + font-family: var(--font-heading), sans-serif; + font-size: clamp(1.9rem, 3.2vw, 2.6rem); + font-weight: 520; + letter-spacing: -0.045em; + line-height: 1.08; +} + +.faqList { + margin: 36px 0 0; +} + +.faqItem { + padding: 22px 0; + border-top: 1px solid var(--line); +} + +.faqItem:last-child { + border-bottom: 1px solid var(--line); +} + +.faqItem dt { + font-size: 1.05rem; + font-weight: 600; + letter-spacing: -0.015em; +} + +.faqItem dd { + margin: 10px 0 0; + color: var(--fg-muted); + line-height: 1.7; +} + +.faqFoot { + margin: 28px 0 0; + color: var(--fg-muted); + font-size: 0.92rem; +} + +.inlineLink { + color: var(--primary); + text-decoration: underline; + text-decoration-color: var(--link-underline); + text-underline-offset: 3px; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 980px) { + .tiers { + grid-template-columns: 1fr; + max-width: 560px; + } + + .tierHighlighted { + order: -1; + } +} + +@media (max-width: 640px) { + .hero { + padding: 72px 16px 28px; + } + + .tiers { + padding: 28px 16px 0; + gap: 14px; + } + + .tier { + padding: 24px 20px 22px; + } + + .tierTagline { + min-height: auto; + } + + .price, + .priceCustom { + font-size: 2.4rem; + } + + .faq { + padding: 80px 16px 72px; + } +} + +@media (prefers-reduced-motion: reduce) { + .tier, + .ctaPrimary svg, + .ctaSecondary svg { + transition: none; + } +} diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index 21b8901c..4aa593ec 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -75,6 +75,12 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: 'monthly', priority: 0.8, }, + { + url: absoluteUrl('/pricing'), + lastModified: now, + changeFrequency: 'monthly', + priority: 0.9, + }, { url: absoluteUrl('/directory'), lastModified: now, diff --git a/web/components/SiteFooter.tsx b/web/components/SiteFooter.tsx index 39c34ba5..c505b1e7 100644 --- a/web/components/SiteFooter.tsx +++ b/web/components/SiteFooter.tsx @@ -21,6 +21,7 @@ const footerColumns: FooterColumn[] = [ title: 'Products', links: [ { label: 'Relay Cloud', href: 'https://agentrelay.com/cloud' }, + { label: 'Pricing', href: '/pricing' }, { label: 'Flows', href: '/flows' }, { label: 'Skip', href: 'https://heyskip.dev' }, { label: 'Pear', href: '/pear' }, diff --git a/web/components/SiteNav.tsx b/web/components/SiteNav.tsx index b91f0453..9d9c7236 100644 --- a/web/components/SiteNav.tsx +++ b/web/components/SiteNav.tsx @@ -101,6 +101,11 @@ export function SiteNav({ Log in +
  • + + Pricing + +
  • Docs @@ -172,6 +177,11 @@ export function SiteNav({ Log in )} + {!hideLinks && ( + setMenuOpen(false)}> + Pricing + + )} {!hideLinks && !hideMobileDocsLink && ( setMenuOpen(false)}> Docs diff --git a/web/content/docs/plans-and-limits.mdx b/web/content/docs/plans-and-limits.mdx new file mode 100644 index 00000000..8f27224f --- /dev/null +++ b/web/content/docs/plans-and-limits.mdx @@ -0,0 +1,72 @@ +--- +title: 'Plans and limits' +description: 'Agent Relay plans are flat monthly prices per organization. Each plan caps devices, agents, and humans, and sets how long session history is kept.' +--- + +An organization subscribes to one plan. The plan is a flat monthly price for the whole organization, not a per-seat charge, and it sets four limits: devices, agents, humans, and session history retention. + +| | Intro | Pro | Enterprise | +|---|---|---|---| +| Devices | 5 | 15 | Unlimited | +| Agents | 15 | 60 | Unlimited | +| Humans | 2 | 10 | Unlimited | +| Session history | 7 days | 14 days | 365 days | +| Price | $99 / month | $199 / month | Sales-only | + +Current prices, including beta pricing, are on the [pricing page](/pricing). + +## What the limits count + +- **Devices** are nodes connected to the organization: a laptop, a CI runner, or a cloud VM running agent relay. A device hosts any number of agents. +- **Agents** are the agent identities registered in the organization's workspaces, across every device. +- **Humans** are the human members of the organization. +- **Session history** is how long session records stay readable. Older sessions are no longer available. + +## Reaching a limit + +Limits are hard caps. The next device, agent, or human past the cap is refused; everything already connected keeps working. The API responds with HTTP `429` and a machine-readable body: + +```json +{ + "error_code": "plan_limit_exceeded", + "limit": "agents", + "max": 15, + "current": 15, + "plan": "intro" +} +``` + +`limit` is one of `devices`, `agents`, or `humans`. The desktop app recognizes `plan_limit_exceeded`, names the limit, and links to the organization's billing settings to upgrade. + +## Subscribing and changing plans + +- A new organization has no plan until it subscribes. Signing up from the pricing page takes you straight to plan selection and checkout; the card is charged immediately and the plan is active right away. +- Intro and Pro are self-serve. Upgrade or cancel from the organization's billing settings in the Cloud dashboard. +- Enterprise is assigned by the Agent Relay team for invoiced customers. [Talk to us](https://agentrelay.com/will) to set it up. + +## Beta pricing + +While the beta is on, Intro and Pro checkouts receive the beta price automatically. A subscription that starts at the beta price keeps it for as long as the subscription stays active. When the beta ends, new subscriptions pay the list price; existing beta subscriptions are unchanged. + +## Existing customers + +Existing customers keep their current access. Organizations created before plans launched are not held to the Intro or Pro caps, are not asked to choose a plan, and can keep using Agent Relay as before. + +## Reading the current plan + +`GET /api/v1/auth/whoami` returns the current workspace's plan alongside its limits, so clients can show usage against the cap before a request is refused: + +```json +{ + "current_workspace": { + "plan": { + "key": "intro", + "name": "Intro", + "limits": { "devices": 5, "agents": 15, "humans": 2, "session_retention_days": 7 }, + "upgrade_url": "https://agentrelay.com/cloud/dashboard/settings#billing" + } + } +} +``` + +`plan` is `null` when the organization has no active plan. `null` inside `limits` means unlimited. diff --git a/web/lib/docs-nav.ts b/web/lib/docs-nav.ts index 7fa5e713..2fb0c467 100644 --- a/web/lib/docs-nav.ts +++ b/web/lib/docs-nav.ts @@ -15,6 +15,7 @@ export const docsNav: NavGroup[] = [ { title: 'Introduction', slug: 'introduction' }, { title: 'Quickstart', slug: 'quickstart' }, { title: 'Workspaces', slug: 'workspaces' }, + { title: 'Plans and limits', slug: 'plans-and-limits' }, { title: 'Authentication', slug: 'authentication' }, ], }, diff --git a/web/lib/pricing.ts b/web/lib/pricing.ts new file mode 100644 index 00000000..3be0b48b --- /dev/null +++ b/web/lib/pricing.ts @@ -0,0 +1,108 @@ +import { googleSignInHref } from './login'; + +/** + * Agent Relay plans as the pricing page and docs present them. Limits mirror + * the canonical `PLAN_LIMITS` table Cloud enforces; `null` means unlimited. + */ +export type PlanKey = 'intro' | 'pro' | 'enterprise'; + +export type PlanLimits = { + devices: number | null; + agents: number | null; + humans: number | null; + session_retention_days: number | null; +}; + +export const PLAN_LIMITS: Record = { + intro: { devices: 5, agents: 15, humans: 2, session_retention_days: 7 }, + pro: { devices: 15, agents: 60, humans: 10, session_retention_days: 14 }, + enterprise: { devices: null, agents: null, humans: null, session_retention_days: 365 }, +}; + +/** + * Beta pricing switch. While `true`, Intro and Pro show the list price struck + * through, the beta price, and the beta note. Set it to `false` when the beta + * ends; nothing else on the site needs to change. + */ +export const BETA_PRICING_ACTIVE = true; + +export const BETA_PRICING_NOTE = 'Beta pricing — locked in for as long as you keep your subscription.'; + +/** Enterprise is sales-led: the CTA books a call instead of starting checkout. */ +export const SALES_HREF = 'https://agentrelay.com/will'; + +export type PricingTier = { + key: PlanKey; + name: string; + tagline: string; + /** Monthly list price in USD; `null` for sales-only tiers. */ + listPrice: number | null; + /** Monthly beta price in USD; `null` when the tier has no beta discount. */ + betaPrice: number | null; + limits: PlanLimits; + highlighted: boolean; + cta: { label: string; href: string }; +}; + +export const pricingTiers: readonly PricingTier[] = [ + { + key: 'intro', + name: 'Intro', + tagline: 'One builder and a small crew of agents across a few machines.', + listPrice: 99, + betaPrice: 49, + limits: PLAN_LIMITS.intro, + highlighted: false, + cta: { label: 'Start with Intro', href: googleSignInHref({ next: '/onboarding/plan?plan=intro' }) }, + }, + { + key: 'pro', + name: 'Pro', + tagline: 'A team of people running a fleet of agents on shared infrastructure.', + listPrice: 199, + betaPrice: 99, + limits: PLAN_LIMITS.pro, + highlighted: true, + cta: { label: 'Start with Pro', href: googleSignInHref({ next: '/onboarding/plan?plan=pro' }) }, + }, + { + key: 'enterprise', + name: 'Enterprise', + tagline: 'Unlimited devices, agents, humans; 1 year history. Invoiced, with deployment options.', + listPrice: null, + betaPrice: null, + limits: PLAN_LIMITS.enterprise, + highlighted: false, + cta: { label: 'Talk to us', href: SALES_HREF }, + }, +]; + +/** Price shown on the card: the beta price while the beta is on, else the list price. */ +export function displayedPrice(tier: PricingTier, betaActive: boolean = BETA_PRICING_ACTIVE): number | null { + return betaActive && tier.betaPrice !== null ? tier.betaPrice : tier.listPrice; +} + +/** True when the card shows the list price struck through next to the beta price. */ +export function showsBetaPrice(tier: PricingTier, betaActive: boolean = BETA_PRICING_ACTIVE): boolean { + return betaActive && tier.betaPrice !== null && tier.listPrice !== null; +} + +export function formatCount(value: number | null, noun: { one: string; many: string }): string { + if (value === null) return `Unlimited ${noun.many}`; + return `${value} ${value === 1 ? noun.one : noun.many}`; +} + +export function formatRetention(days: number | null): string { + if (days === null) return 'Unlimited session history'; + if (days === 365) return '1 year of session history'; + return `${days}-day session history`; +} + +export function limitLines(limits: PlanLimits): readonly string[] { + return [ + formatCount(limits.devices, { one: 'device', many: 'devices' }), + formatCount(limits.agents, { one: 'agent', many: 'agents' }), + formatCount(limits.humans, { one: 'human', many: 'humans' }), + formatRetention(limits.session_retention_days), + ]; +} diff --git a/web/lib/test/pricing-links.test.tsx b/web/lib/test/pricing-links.test.tsx new file mode 100644 index 00000000..7b1bcd76 --- /dev/null +++ b/web/lib/test/pricing-links.test.tsx @@ -0,0 +1,21 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('next/navigation', () => ({ usePathname: () => '/' })); + +import { SiteFooter } from '../../components/SiteFooter'; +import { SiteNav } from '../../components/SiteNav'; + +describe('pricing links', () => { + it('appear in the site nav', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('href="/pricing"'); + expect(html).toContain('>Pricing<'); + }); + + it('appear in the site footer', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('href="/pricing"'); + expect(html).toContain('>Pricing<'); + }); +}); diff --git a/web/lib/test/pricing-page.test.tsx b/web/lib/test/pricing-page.test.tsx new file mode 100644 index 00000000..e8e57dcf --- /dev/null +++ b/web/lib/test/pricing-page.test.tsx @@ -0,0 +1,127 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../components/SiteFooter', () => ({ SiteFooter: () => null })); +vi.mock('../../components/SiteNav', () => ({ SiteNav: () => null })); + +import sitemap from '../../app/sitemap'; +import PricingPage, { metadata } from '../../app/pricing/page'; +import { docsNav } from '../docs-nav'; +import { getDoc } from '../docs'; +import { googleSignInHref } from '../login'; +import { + BETA_PRICING_ACTIVE, + BETA_PRICING_NOTE, + PLAN_LIMITS, + SALES_HREF, + displayedPrice, + limitLines, + pricingTiers, + showsBetaPrice, +} from '../pricing'; + +const html = renderToStaticMarkup(); + +describe('pricing page', () => { + it('shows the three tiers with the contract limits', () => { + expect(html).toContain('>Intro<'); + expect(html).toContain('>Pro<'); + expect(html).toContain('>Enterprise<'); + expect(html).toContain('5 devices'); + expect(html).toContain('15 agents'); + expect(html).toContain('2 humans'); + expect(html).toContain('7-day session history'); + expect(html).toContain('15 devices'); + expect(html).toContain('60 agents'); + expect(html).toContain('10 humans'); + expect(html).toContain('14-day session history'); + expect(html).toContain('Unlimited devices'); + expect(html).toContain('Unlimited agents'); + expect(html).toContain('Unlimited humans'); + expect(html).toContain('1 year of session history'); + }); + + it('shows beta pricing with the list price struck through while the beta is on', () => { + expect(BETA_PRICING_ACTIVE).toBe(true); + expect(html).toContain('$99'); + expect(html).toContain('$49'); + expect(html).toContain('$199'); + expect(html).toContain('List price $99'); + expect(html).toContain('List price $199'); + expect(html).toContain(BETA_PRICING_NOTE); + }); + + it('sends Intro and Pro through Google sign-in to plan selection, and Enterprise to sales', () => { + const intro = googleSignInHref({ next: '/onboarding/plan?plan=intro' }); + const pro = googleSignInHref({ next: '/onboarding/plan?plan=pro' }); + expect(intro).toBe('/cloud/api/auth/google/start?next=%2Fonboarding%2Fplan%3Fplan%3Dintro'); + expect(html).toContain(`href="${intro.replace(/&/g, '&')}"`); + expect(html).toContain(`href="${pro.replace(/&/g, '&')}"`); + expect(html).toContain(`href="${SALES_HREF}"`); + expect(html).toContain('Talk to us'); + }); + + it('answers the four FAQ questions, with existing customers keeping their access', () => { + expect(html).toContain('What counts as a device?'); + expect(html).toContain('What happens when I reach a limit?'); + expect(html).toContain('Can I change plans?'); + expect(html).toContain('How do limits apply to existing customers?'); + expect(html).toContain('Existing customers keep their current access.'); + }); + + it('uses the customer-facing product name only', () => { + expect(html.toLowerCase()).not.toContain('relaycast'); + expect(html.toLowerCase()).not.toContain('broker'); + }); + + it('has a canonical URL and appears in the sitemap', () => { + expect(metadata.title).toBe('Pricing'); + expect(metadata.alternates?.canonical).toBe('https://agentrelay.com/pricing'); + expect(sitemap().map((entry) => entry.url)).toContain('https://agentrelay.com/pricing'); + }); +}); + +describe('pricing data', () => { + it('matches the canonical plan limits', () => { + expect(PLAN_LIMITS).toEqual({ + intro: { devices: 5, agents: 15, humans: 2, session_retention_days: 7 }, + pro: { devices: 15, agents: 60, humans: 10, session_retention_days: 14 }, + enterprise: { devices: null, agents: null, humans: null, session_retention_days: 365 }, + }); + expect(pricingTiers.map((tier) => tier.key)).toEqual(['intro', 'pro', 'enterprise']); + expect(pricingTiers.filter((tier) => tier.highlighted).map((tier) => tier.key)).toEqual(['pro']); + }); + + it('is a single switch: beta off shows list prices with no strikethrough', () => { + const [intro, pro, enterprise] = pricingTiers; + expect(displayedPrice(intro, true)).toBe(49); + expect(displayedPrice(pro, true)).toBe(99); + expect(displayedPrice(intro, false)).toBe(99); + expect(displayedPrice(pro, false)).toBe(199); + expect(displayedPrice(enterprise, true)).toBeNull(); + expect(showsBetaPrice(intro, true)).toBe(true); + expect(showsBetaPrice(intro, false)).toBe(false); + expect(showsBetaPrice(enterprise, true)).toBe(false); + }); + + it('renders enterprise limits as unlimited with one year of history', () => { + expect(limitLines(PLAN_LIMITS.enterprise)).toEqual([ + 'Unlimited devices', + 'Unlimited agents', + 'Unlimited humans', + '1 year of session history', + ]); + }); +}); + +describe('plans and limits docs page', () => { + it('is in the docs nav and loads', () => { + const slugs = docsNav.flatMap((group) => group.items.map((item) => item.slug)); + expect(slugs).toContain('plans-and-limits'); + const doc = getDoc('plans-and-limits'); + expect(doc?.frontmatter.title).toBe('Plans and limits'); + expect(doc?.content).toContain('plan_limit_exceeded'); + expect(doc?.content).toContain('Existing customers keep their current access'); + expect(sitemap().map((entry) => entry.url)).toContain('https://agentrelay.com/docs/plans-and-limits'); + }); +}); From 5c267bb327ef470cf5846d59f8f39c9fd4453022 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sun, 25 Oct 2026 15:56:47 -0700 Subject: [PATCH 2/2] Keep pricing card order equal to focus order at narrow widths The single-column layout now follows document order (Intro, Pro, Enterprise) so keyboard focus moves through the cards as displayed. Co-Authored-By: Claude Fable 5.1 --- web/app/pricing/pricing.module.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/web/app/pricing/pricing.module.css b/web/app/pricing/pricing.module.css index cf2b4e71..5c170994 100644 --- a/web/app/pricing/pricing.module.css +++ b/web/app/pricing/pricing.module.css @@ -355,10 +355,6 @@ grid-template-columns: 1fr; max-width: 560px; } - - .tierHighlighted { - order: -1; - } } @media (max-width: 640px) {