feat(pricing): add /pricing page (B-style)
Phase 2 Task 42: public pricing page gated by SELF_SERVE_ENABLED. Backend: - New `GET /api/v1/plans/public` (no auth) returns plan_billing rows joined with plan_limits.max_users (as `max_seats`), filtered to is_public=true AND is_archived=false, ordered by sort_order ASC, plan ASC. Uses get_admin_db (cross-tenant catalog read, same pattern as /config/public). - `PublicPlanResponse` schema in app/schemas/billing.py. - Registered as PUBLIC in api router. Frontend: - `plansApi.getPublic()` client (frontend/src/api/plans.ts). - `PricingPage` at /pricing with hero / 3 plan cards (Pro recommended, Enterprise hides price) / hardcoded v1 comparison table / testimonial placeholder / soft trust strip. - Reads `useAppConfig().self_serve_enabled`; renders a 404 fallback when disabled, never calls the API in that path. - Start free trial CTAs link to /register?plan=starter|pro; Talk to sales links to /contact-sales (page wired in Task 43). Tests: - Backend: only-public-rows + sort-order ordering. - Frontend (Vitest): three plan cards with API prices, /register?plan=pro CTA, /contact-sales CTA, 404 when self_serve_enabled is false, soft trust language (no SOC2 claim). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
58
backend/app/api/endpoints/plans_public.py
Normal file
58
backend/app/api/endpoints/plans_public.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Public plans endpoint — no auth required.
|
||||
|
||||
GET /api/v1/plans/public
|
||||
Returns the public-safe view of `plan_billing` joined with
|
||||
`plan_limits.max_users` (exposed as `max_seats`), filtered to
|
||||
`is_public=True AND is_archived=False`, ordered by sort_order ASC, plan ASC.
|
||||
|
||||
Distinct from `/admin/plan-limits` (admin-only, returns ALL plans including
|
||||
archived/internal). This endpoint exists to power the marketing /pricing page
|
||||
without exposing the rest of the admin-only billing surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.models.plan_billing import PlanBilling
|
||||
from app.models.plan_limits import PlanLimits
|
||||
from app.schemas.billing import PublicPlanResponse
|
||||
|
||||
router = APIRouter(prefix="/plans", tags=["plans"])
|
||||
|
||||
|
||||
@router.get("/public", response_model=list[PublicPlanResponse])
|
||||
async def list_public_plans(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> list[PublicPlanResponse]:
|
||||
"""List public, non-archived plans for the marketing /pricing page.
|
||||
|
||||
Public — no auth. Uses `get_admin_db` because this is a cross-tenant read
|
||||
of the global plan catalog (same pattern as `/config/public`).
|
||||
"""
|
||||
stmt = (
|
||||
select(PlanBilling, PlanLimits.max_users)
|
||||
.outerjoin(PlanLimits, PlanBilling.plan == PlanLimits.plan)
|
||||
.where(PlanBilling.is_public.is_(True))
|
||||
.where(PlanBilling.is_archived.is_(False))
|
||||
.order_by(PlanBilling.sort_order.asc(), PlanBilling.plan.asc())
|
||||
)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
return [
|
||||
PublicPlanResponse(
|
||||
plan=billing.plan,
|
||||
display_name=billing.display_name,
|
||||
description=billing.description,
|
||||
monthly_price_cents=billing.monthly_price_cents,
|
||||
annual_price_cents=billing.annual_price_cents,
|
||||
max_seats=max_users,
|
||||
sort_order=billing.sort_order,
|
||||
is_public=billing.is_public,
|
||||
)
|
||||
for billing, max_users in rows
|
||||
]
|
||||
@@ -45,6 +45,7 @@ from app.api.endpoints import (
|
||||
notifications,
|
||||
oauth as oauth_endpoints,
|
||||
onboarding,
|
||||
plans_public,
|
||||
public_templates,
|
||||
ratings,
|
||||
scripts,
|
||||
@@ -97,6 +98,7 @@ api_router.include_router(public_templates.router) # Public gallery (no auth, r
|
||||
api_router.include_router(survey.router) # Public survey flow (no auth, rate-limited)
|
||||
api_router.include_router(config_endpoints.router) # Public runtime feature flags
|
||||
api_router.include_router(account_invite_lookup.router) # Public invite-code lookup for /accept-invite
|
||||
api_router.include_router(plans_public.router) # Public plan catalog for /pricing page
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin endpoints — super_admin only
|
||||
|
||||
@@ -42,3 +42,23 @@ class BillingStateResponse(BaseModel):
|
||||
plan_billing: Optional[PlanBillingState]
|
||||
plan_limits: Dict[str, Any]
|
||||
enabled_features: Dict[str, bool]
|
||||
|
||||
|
||||
class PublicPlanResponse(BaseModel):
|
||||
"""Public-safe view of a billable plan, used by the marketing /pricing page.
|
||||
|
||||
Sourced from `plan_billing` joined with `plan_limits.max_users` (exposed
|
||||
here as `max_seats`). Always filtered server-side to is_public=True and
|
||||
is_archived=False, so `is_public` is a constant True for any row returned
|
||||
here — included for clarity and forward compatibility.
|
||||
"""
|
||||
plan: str
|
||||
display_name: str
|
||||
description: Optional[str] = None
|
||||
monthly_price_cents: Optional[int] = None
|
||||
annual_price_cents: Optional[int] = None
|
||||
max_seats: Optional[int] = None
|
||||
sort_order: int
|
||||
is_public: bool = True
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
132
backend/tests/test_plans_public.py
Normal file
132
backend/tests/test_plans_public.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Integration tests for the public plans endpoint.
|
||||
|
||||
Covers GET /api/v1/plans/public — the marketing /pricing page data source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.models.plan_billing import PlanBilling
|
||||
from app.models.plan_limits import PlanLimits
|
||||
|
||||
|
||||
async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None:
|
||||
"""Ensure a plan_limits row exists for the given plan name."""
|
||||
existing = await test_db.get(PlanLimits, plan)
|
||||
if existing is None:
|
||||
test_db.add(
|
||||
PlanLimits(
|
||||
plan=plan,
|
||||
max_trees=None,
|
||||
max_sessions_per_month=None,
|
||||
max_users=max_users,
|
||||
custom_branding=False,
|
||||
priority_support=False,
|
||||
export_formats=["markdown", "text"],
|
||||
)
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
|
||||
class TestGetPlansPublic:
|
||||
"""GET /api/v1/plans/public — anonymous, no auth."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_plans_public_returns_only_is_public_rows(
|
||||
self, client: AsyncClient, test_db
|
||||
):
|
||||
"""Rows with is_public=False or is_archived=True must NOT appear."""
|
||||
# Wipe any existing billing rows so this test owns the fixture state.
|
||||
await test_db.execute(delete(PlanBilling))
|
||||
await test_db.commit()
|
||||
|
||||
await _seed_plan_limits(test_db, "starter", 3)
|
||||
await _seed_plan_limits(test_db, "pro", 10)
|
||||
await _seed_plan_limits(test_db, "internal", None)
|
||||
await _seed_plan_limits(test_db, "legacy", 5)
|
||||
|
||||
test_db.add_all(
|
||||
[
|
||||
PlanBilling(
|
||||
plan="starter",
|
||||
display_name="Starter",
|
||||
monthly_price_cents=1900,
|
||||
is_public=True,
|
||||
is_archived=False,
|
||||
sort_order=10,
|
||||
),
|
||||
PlanBilling(
|
||||
plan="pro",
|
||||
display_name="Pro",
|
||||
monthly_price_cents=4900,
|
||||
is_public=True,
|
||||
is_archived=False,
|
||||
sort_order=20,
|
||||
),
|
||||
PlanBilling(
|
||||
plan="internal",
|
||||
display_name="Internal",
|
||||
is_public=False, # hidden
|
||||
is_archived=False,
|
||||
sort_order=30,
|
||||
),
|
||||
PlanBilling(
|
||||
plan="legacy",
|
||||
display_name="Legacy",
|
||||
is_public=True,
|
||||
is_archived=True, # archived
|
||||
sort_order=40,
|
||||
),
|
||||
]
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get("/api/v1/plans/public")
|
||||
assert response.status_code == 200
|
||||
plans = response.json()
|
||||
plan_names = {p["plan"] for p in plans}
|
||||
|
||||
assert "starter" in plan_names
|
||||
assert "pro" in plan_names
|
||||
assert "internal" not in plan_names
|
||||
assert "legacy" not in plan_names
|
||||
|
||||
# Schema sanity check
|
||||
starter = next(p for p in plans if p["plan"] == "starter")
|
||||
assert starter["display_name"] == "Starter"
|
||||
assert starter["monthly_price_cents"] == 1900
|
||||
assert starter["max_seats"] == 3
|
||||
assert starter["is_public"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_plans_public_orders_by_sort_order_then_plan(
|
||||
self, client: AsyncClient, test_db
|
||||
):
|
||||
"""Result must be ordered by sort_order ASC, then plan name ASC."""
|
||||
await test_db.execute(delete(PlanBilling))
|
||||
await test_db.commit()
|
||||
|
||||
# plan_limits rows for FK satisfaction
|
||||
for name in ("alpha", "bravo", "charlie", "delta"):
|
||||
await _seed_plan_limits(test_db, name, None)
|
||||
|
||||
# Two with sort_order=10 (charlie should come before delta by plan ASC),
|
||||
# one with sort_order=5 (alpha first overall),
|
||||
# one with sort_order=20 (bravo last).
|
||||
test_db.add_all(
|
||||
[
|
||||
PlanBilling(plan="charlie", display_name="C", sort_order=10, is_public=True, is_archived=False),
|
||||
PlanBilling(plan="delta", display_name="D", sort_order=10, is_public=True, is_archived=False),
|
||||
PlanBilling(plan="alpha", display_name="A", sort_order=5, is_public=True, is_archived=False),
|
||||
PlanBilling(plan="bravo", display_name="B", sort_order=20, is_public=True, is_archived=False),
|
||||
]
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get("/api/v1/plans/public")
|
||||
assert response.status_code == 200
|
||||
ordered = [p["plan"] for p in response.json()]
|
||||
assert ordered == ["alpha", "charlie", "delta", "bravo"]
|
||||
@@ -10,6 +10,8 @@ export { default as stepsApi } from './steps'
|
||||
export { default as stepCategoriesApi } from './stepCategories'
|
||||
export { default as accountsApi } from './accounts'
|
||||
export { default as billingApi } from './billing'
|
||||
export { default as plansApi } from './plans'
|
||||
export type { PublicPlanResponse } from './plans'
|
||||
export { default as usageApi } from './usage'
|
||||
export { default as adminApi } from './admin'
|
||||
export { treeMarkdownApi } from './treeMarkdown'
|
||||
|
||||
22
frontend/src/api/plans.ts
Normal file
22
frontend/src/api/plans.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface PublicPlanResponse {
|
||||
plan: string
|
||||
display_name: string
|
||||
description: string | null
|
||||
monthly_price_cents: number | null
|
||||
annual_price_cents: number | null
|
||||
max_seats: number | null
|
||||
sort_order: number
|
||||
is_public: boolean
|
||||
}
|
||||
|
||||
export const plansApi = {
|
||||
/** Public plan catalog for the marketing /pricing page. No auth. */
|
||||
async getPublic(): Promise<PublicPlanResponse[]> {
|
||||
const response = await apiClient.get<PublicPlanResponse[]>('/plans/public')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default plansApi
|
||||
440
frontend/src/pages/PricingPage.tsx
Normal file
440
frontend/src/pages/PricingPage.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { plansApi, type PublicPlanResponse } from '@/api/plans'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { useAppConfig } from '@/hooks/useAppConfig'
|
||||
import '@/styles/landing.css'
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* v1 hardcoded comparison table
|
||||
*
|
||||
* The marketing /pricing page surfaces a small "what's in each plan" table.
|
||||
* Long-term, the source of truth for "plan X has feature Y" should be a
|
||||
* server-side feature-flag mapping (likely keyed off feature_flag.display_name
|
||||
* + plan_features). For v1 we hardcode the well-known features so we can ship
|
||||
* the page without a backend dependency. Replace this block when a server-side
|
||||
* feature mapping endpoint exists.
|
||||
* ------------------------------------------------------------------------- */
|
||||
type PlanColumn = 'starter' | 'pro' | 'enterprise'
|
||||
|
||||
const COMPARISON_ROWS: Array<{
|
||||
feature: string
|
||||
values: Record<PlanColumn, boolean>
|
||||
}> = [
|
||||
{ feature: 'PSA Integration', values: { starter: false, pro: true, enterprise: true } },
|
||||
{ feature: 'KB Accelerator', values: { starter: false, pro: true, enterprise: true } },
|
||||
{ feature: 'AI Builder', values: { starter: true, pro: true, enterprise: true } },
|
||||
{ feature: 'Custom Branding', values: { starter: false, pro: false, enterprise: true } },
|
||||
{ feature: 'Priority Support', values: { starter: false, pro: true, enterprise: true } },
|
||||
]
|
||||
|
||||
function formatPrice(cents: number | null | undefined): string {
|
||||
if (cents == null) return ''
|
||||
const dollars = cents / 100
|
||||
// Whole dollars (no decimals) for marketing display.
|
||||
return `$${Math.round(dollars).toLocaleString()}`
|
||||
}
|
||||
|
||||
function PricingNotFound() {
|
||||
return (
|
||||
<div
|
||||
data-testid="pricing-not-found"
|
||||
style={{
|
||||
minHeight: '60vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
padding: '2rem',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>Page not found</h1>
|
||||
<p style={{ color: '#9198a8' }}>This page is not available.</p>
|
||||
<Link to="/login" style={{ color: '#60a5fa', marginTop: '1rem' }}>
|
||||
Go to login
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: PublicPlanResponse | null
|
||||
fallback: {
|
||||
plan: string
|
||||
display_name: string
|
||||
description: string
|
||||
}
|
||||
recommended?: boolean
|
||||
hidePrice?: boolean
|
||||
ctaLabel: string
|
||||
ctaHref: string
|
||||
ctaTestId: string
|
||||
}
|
||||
|
||||
function PlanCard({ plan, fallback, recommended, hidePrice, ctaLabel, ctaHref, ctaTestId }: PlanCardProps) {
|
||||
const displayName = plan?.display_name ?? fallback.display_name
|
||||
const description = plan?.description ?? fallback.description
|
||||
const monthlyCents = plan?.monthly_price_cents ?? null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`plan-card-${fallback.plan}`}
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: 'var(--lp-card)',
|
||||
border: recommended
|
||||
? '2px solid var(--lp-accent)'
|
||||
: '1px solid var(--lp-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '2rem 1.75rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.25rem',
|
||||
}}
|
||||
>
|
||||
{recommended && (
|
||||
<span
|
||||
data-testid="recommended-badge"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: '4px 12px',
|
||||
background: 'var(--lp-accent)',
|
||||
color: '#0d0f15',
|
||||
borderRadius: '999px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</h3>
|
||||
<p style={{ margin: '0.5rem 0 0', color: 'var(--lp-text-secondary)', fontSize: '0.9rem' }}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ minHeight: '3rem' }}>
|
||||
{hidePrice ? (
|
||||
<div style={{ color: 'var(--lp-text-heading)', fontSize: '1.25rem', fontWeight: 600 }}>
|
||||
Custom pricing
|
||||
</div>
|
||||
) : monthlyCents != null ? (
|
||||
<div>
|
||||
<span style={{ color: 'var(--lp-text-heading)', fontSize: '2.25rem', fontWeight: 700 }}>
|
||||
{formatPrice(monthlyCents)}
|
||||
</span>
|
||||
<span style={{ color: 'var(--lp-text-secondary)', marginLeft: '0.35rem' }}>/ month</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--lp-text-secondary)' }}>Contact us</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={ctaHref}
|
||||
data-testid={ctaTestId}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
textAlign: 'center',
|
||||
padding: '0.75rem 1.25rem',
|
||||
background: recommended ? 'var(--lp-accent)' : 'transparent',
|
||||
color: recommended ? '#0d0f15' : 'var(--lp-text-heading)',
|
||||
border: recommended ? 'none' : '1px solid var(--lp-border-hover)',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{ctaLabel}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PricingPage() {
|
||||
const appConfig = useAppConfig()
|
||||
const [plans, setPlans] = useState<PublicPlanResponse[] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Fetch plans on mount when self-serve is enabled.
|
||||
useEffect(() => {
|
||||
if (appConfig.isLoading) return
|
||||
if (!appConfig.self_serve_enabled) return
|
||||
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
plansApi
|
||||
.getPublic()
|
||||
.then((data) => {
|
||||
if (cancelled) return
|
||||
setPlans(data)
|
||||
setError(null)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
// Non-fatal: page still renders with fallback descriptions and no
|
||||
// server-driven prices. The CTA still works via /register?plan=...
|
||||
setError('Unable to load live pricing.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [appConfig.isLoading, appConfig.self_serve_enabled])
|
||||
|
||||
// Self-serve disabled: render a 404-style fallback. Done after hooks so
|
||||
// the React rules-of-hooks invariant holds.
|
||||
if (!appConfig.isLoading && !appConfig.self_serve_enabled) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Page not found" />
|
||||
<PricingNotFound />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const planByName = (name: string) =>
|
||||
plans?.find((p) => p.plan.toLowerCase() === name) ?? null
|
||||
|
||||
return (
|
||||
<div className="landing-page">
|
||||
<PageMeta
|
||||
title="Pricing"
|
||||
description="ResolutionFlow plans for MSPs — Starter, Pro, and Enterprise. Try Pro free for 14 days, no credit card required."
|
||||
/>
|
||||
|
||||
<main className="landing-main" style={{ paddingTop: '4rem', paddingBottom: '4rem' }}>
|
||||
{/* ---- HERO ---- */}
|
||||
<section
|
||||
style={{
|
||||
maxWidth: '720px',
|
||||
margin: '0 auto',
|
||||
padding: '4rem 1.5rem 2rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: 'clamp(2rem, 4vw, 2.75rem)',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.15,
|
||||
margin: '0 0 1rem',
|
||||
}}
|
||||
>
|
||||
Simple pricing for MSPs of every size
|
||||
</h1>
|
||||
<p
|
||||
data-testid="hero-trial-line"
|
||||
style={{
|
||||
color: 'var(--lp-text-body)',
|
||||
fontSize: '1.125rem',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Try Pro free for 14 days. No credit card required.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ---- PLAN CARDS ---- */}
|
||||
<section
|
||||
aria-label="Plans"
|
||||
style={{
|
||||
maxWidth: '1100px',
|
||||
margin: '0 auto',
|
||||
padding: '2rem 1.5rem',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<PlanCard
|
||||
plan={planByName('starter')}
|
||||
fallback={{
|
||||
plan: 'starter',
|
||||
display_name: 'Starter',
|
||||
description: 'For solo techs getting structured.',
|
||||
}}
|
||||
ctaLabel="Start free trial"
|
||||
ctaHref="/register?plan=starter"
|
||||
ctaTestId="cta-starter"
|
||||
/>
|
||||
<PlanCard
|
||||
plan={planByName('pro')}
|
||||
recommended
|
||||
fallback={{
|
||||
plan: 'pro',
|
||||
display_name: 'Pro',
|
||||
description: 'For growing MSP teams. PSA integration + KB Accelerator.',
|
||||
}}
|
||||
ctaLabel="Start free trial"
|
||||
ctaHref="/register?plan=pro"
|
||||
ctaTestId="cta-pro"
|
||||
/>
|
||||
<PlanCard
|
||||
plan={planByName('enterprise')}
|
||||
hidePrice
|
||||
fallback={{
|
||||
plan: 'enterprise',
|
||||
display_name: 'Enterprise',
|
||||
description: 'Custom branding, custom seats, and a dedicated success contact.',
|
||||
}}
|
||||
ctaLabel="Talk to sales"
|
||||
ctaHref="/contact-sales"
|
||||
ctaTestId="cta-enterprise"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{loading && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
style={{ textAlign: 'center', color: 'var(--lp-text-secondary)' }}
|
||||
>
|
||||
Loading pricing…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
role="status"
|
||||
style={{ textAlign: 'center', color: 'var(--lp-text-secondary)', marginTop: '0.5rem' }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- COMPARISON TABLE ---- */}
|
||||
<section
|
||||
aria-label="Plan comparison"
|
||||
style={{
|
||||
maxWidth: '1000px',
|
||||
margin: '3rem auto 2rem',
|
||||
padding: '0 1.5rem',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
color: 'var(--lp-text-heading)',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
margin: '0 0 1rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Compare plans
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
overflowX: 'auto',
|
||||
border: '1px solid var(--lp-border)',
|
||||
borderRadius: '12px',
|
||||
}}
|
||||
>
|
||||
<table
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
color: 'var(--lp-text-body)',
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--lp-bg-alt)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem 1rem', fontWeight: 600 }}>
|
||||
Feature
|
||||
</th>
|
||||
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Starter</th>
|
||||
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Pro</th>
|
||||
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Enterprise</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMPARISON_ROWS.map((row) => (
|
||||
<tr key={row.feature} style={{ borderTop: '1px solid var(--lp-border)' }}>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>{row.feature}</td>
|
||||
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
|
||||
{row.values.starter ? '✓' : '—'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
|
||||
{row.values.pro ? '✓' : '—'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
|
||||
{row.values.enterprise ? '✓' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- TESTIMONIAL SLOT (placeholder) ---- */}
|
||||
<section
|
||||
aria-label="Customer testimonial"
|
||||
style={{
|
||||
maxWidth: '720px',
|
||||
margin: '3rem auto 2rem',
|
||||
padding: '2rem 1.5rem',
|
||||
background: 'var(--lp-card)',
|
||||
border: '1px solid var(--lp-border)',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
data-testid="testimonial-slot"
|
||||
>
|
||||
<blockquote
|
||||
style={{
|
||||
fontStyle: 'italic',
|
||||
color: 'var(--lp-text-body)',
|
||||
fontSize: '1.05rem',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
"Pilot testimonials coming soon."
|
||||
</blockquote>
|
||||
<div style={{ marginTop: '0.75rem', color: 'var(--lp-text-secondary)', fontSize: '0.9rem' }}>
|
||||
ResolutionFlow pilot, 2026
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- TRUST STRIP ---- */}
|
||||
<section
|
||||
aria-label="Trust"
|
||||
data-testid="trust-strip"
|
||||
style={{
|
||||
maxWidth: '900px',
|
||||
margin: '2rem auto 0',
|
||||
padding: '1rem 1.5rem',
|
||||
color: 'var(--lp-text-secondary)',
|
||||
fontSize: '0.9rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Built on Stripe + AWS · Encrypted in transit and at rest
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PricingPage
|
||||
162
frontend/src/pages/__tests__/PricingPage.test.tsx
Normal file
162
frontend/src/pages/__tests__/PricingPage.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { HelmetProvider } from 'react-helmet-async'
|
||||
|
||||
import { PricingPage } from '../PricingPage'
|
||||
import { plansApi, type PublicPlanResponse } from '@/api/plans'
|
||||
import {
|
||||
__resetAppConfigCache,
|
||||
__setAppConfigCache,
|
||||
} from '@/hooks/useAppConfig'
|
||||
|
||||
vi.mock('@/api/plans', () => ({
|
||||
plansApi: {
|
||||
getPublic: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const STARTER: PublicPlanResponse = {
|
||||
plan: 'starter',
|
||||
display_name: 'Starter',
|
||||
description: 'For solo techs.',
|
||||
monthly_price_cents: 1900,
|
||||
annual_price_cents: 19000,
|
||||
max_seats: 3,
|
||||
sort_order: 10,
|
||||
is_public: true,
|
||||
}
|
||||
|
||||
const PRO: PublicPlanResponse = {
|
||||
plan: 'pro',
|
||||
display_name: 'Pro',
|
||||
description: 'For growing MSP teams.',
|
||||
monthly_price_cents: 4900,
|
||||
annual_price_cents: 49000,
|
||||
max_seats: 10,
|
||||
sort_order: 20,
|
||||
is_public: true,
|
||||
}
|
||||
|
||||
const ENTERPRISE: PublicPlanResponse = {
|
||||
plan: 'enterprise',
|
||||
display_name: 'Enterprise',
|
||||
description: 'Custom seats + branding.',
|
||||
monthly_price_cents: null,
|
||||
annual_price_cents: null,
|
||||
max_seats: null,
|
||||
sort_order: 30,
|
||||
is_public: true,
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<HelmetProvider>
|
||||
<MemoryRouter initialEntries={['/pricing']}>
|
||||
<PricingPage />
|
||||
</MemoryRouter>
|
||||
</HelmetProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('PricingPage', () => {
|
||||
beforeEach(() => {
|
||||
__resetAppConfigCache()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows three plan cards with prices from API', async () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(plansApi.getPublic).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Three plan cards present.
|
||||
expect(screen.getByTestId('plan-card-starter')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plan-card-enterprise')).toBeInTheDocument()
|
||||
|
||||
// Prices from API rendered.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('$19')).toBeInTheDocument()
|
||||
expect(screen.getByText('$49')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Enterprise card hides price (shows "Custom pricing" instead).
|
||||
expect(screen.getByText(/Custom pricing/i)).toBeInTheDocument()
|
||||
|
||||
// Pro is recommended.
|
||||
expect(screen.getByTestId('recommended-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Start free trial button navigates to /register?plan=pro', async () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
|
||||
|
||||
renderPage()
|
||||
|
||||
const proCta = await screen.findByTestId('cta-pro')
|
||||
expect(proCta).toHaveAttribute('href', '/register?plan=pro')
|
||||
expect(proCta).toHaveTextContent(/Start free trial/i)
|
||||
|
||||
const starterCta = screen.getByTestId('cta-starter')
|
||||
expect(starterCta).toHaveAttribute('href', '/register?plan=starter')
|
||||
})
|
||||
|
||||
it('Talk to sales button navigates to /contact-sales', async () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
|
||||
|
||||
renderPage()
|
||||
|
||||
const enterpriseCta = await screen.findByTestId('cta-enterprise')
|
||||
expect(enterpriseCta).toHaveAttribute('href', '/contact-sales')
|
||||
expect(enterpriseCta).toHaveTextContent(/Talk to sales/i)
|
||||
})
|
||||
|
||||
it('returns 404 when self_serve_enabled is false', async () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: false,
|
||||
oauth_providers: [],
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('pricing-not-found')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText(/Page not found/i)).toBeInTheDocument()
|
||||
|
||||
// No plan cards rendered, no API call made.
|
||||
expect(screen.queryByTestId('plan-card-starter')).not.toBeInTheDocument()
|
||||
expect(plansApi.getPublic).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses softer trust language (no SOC2/DPA claim yet)', async () => {
|
||||
__setAppConfigCache({
|
||||
self_serve_enabled: true,
|
||||
oauth_providers: [],
|
||||
})
|
||||
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
|
||||
|
||||
renderPage()
|
||||
|
||||
const trust = await screen.findByTestId('trust-strip')
|
||||
expect(trust).toHaveTextContent(/Built on Stripe \+ AWS/i)
|
||||
expect(trust).toHaveTextContent(/Encrypted in transit and at rest/i)
|
||||
expect(trust).not.toHaveTextContent(/SOC ?2/i)
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,7 @@ const SurveyPage = lazyWithRetry(() => import('@/pages/SurveyPage'))
|
||||
const SurveyThankYouPage = lazyWithRetry(() => import('@/pages/SurveyThankYouPage'))
|
||||
const PrivacyPage = lazyWithRetry(() => import('@/pages/PrivacyPage'))
|
||||
const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage'))
|
||||
const PricingPage = lazyWithRetry(() => import('@/pages/PricingPage'))
|
||||
|
||||
// Standalone auth pages
|
||||
const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage'))
|
||||
@@ -131,6 +132,11 @@ export const router = sentryCreateBrowserRouter([
|
||||
element: page(TermsPage),
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/pricing',
|
||||
element: page(PricingPage),
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage />,
|
||||
|
||||
Reference in New Issue
Block a user