Files
resolutionflow/frontend/src/pages/PricingPage.tsx
Michael Chihlas ba45cfeec1
All checks were successful
CI / frontend (push) Successful in 6m47s
Mirror to GitHub / mirror (push) Successful in 6s
CI / e2e (push) Successful in 10m16s
CI / backend (push) Successful in 11m13s
feat(legal): add /policies, /contact, /promotions pages + MarketingFooter (#165)
Adds the three legal/contact pages needed for Stripe live-mode site review: /policies (consolidated customer policies — refunds, cancellation, legal restrictions, promotions), /contact (phone (470) 949-4131 + support/sales/billing/security inboxes), /promotions (stub satisfying §6.2 cross-ref).

Extracts the existing landing footer into components/common/MarketingFooter.tsx and mounts it on /pricing and /contact-sales so all four legal links are reachable from every marketing surface.

Privacy and Terms closing sections updated to point at /contact + /policies; stale hello@ mailto removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
2026-05-12 05:23:43 +00:00

443 lines
14 KiB
TypeScript

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 { MarketingFooter } from '@/components/common/MarketingFooter'
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
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>
<MarketingFooter />
</main>
</div>
)
}
export default PricingPage