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>
443 lines
14 KiB
TypeScript
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
|