feat(sales): add /contact-sales form + landing page CTA

Public Talk-to-Sales surface and a "See pricing" hero CTA on the marketing
landing page. Phase 2 Task 43 of self-serve signup.

- frontend/src/api/sales.ts: salesApi.createLead -> POST /sales-leads.
- ContactSalesPage at /contact-sales (public, gated by self_serve_enabled
  with a 404-style fallback). Form fields: name, work email, company,
  team size (1-2 / 3-5 / 6-10 / 11-25 / 26+), and an optional
  "what brought you here?" textarea -> message. Submit button disabled
  while in flight to block duplicate submissions.
- Confirmation surface replaces the form on success. Calendly block is
  hidden when VITE_CALENDLY_URL is unset.
- detectSource(): 'pricing_page' if document.referrer contains '/pricing',
  else 'landing_page'. Server emits the canonical PostHog
  talk_to_sales_form_submitted event with this source.
- LandingPage: new "See pricing" hero CTA gated by useAppConfig().
  self_serve_enabled.
- frontend/.env.example + Dockerfile: VITE_CALENDLY_URL ARG/ENV.
- Tests: ContactSalesPage submit/confirmation, Calendly hide-when-unset,
  in-flight de-dup, 404 when self-serve off; LandingPage CTA on/off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 23:31:56 -04:00
parent 67fae91087
commit db2478dd89
9 changed files with 673 additions and 0 deletions

View File

@@ -21,3 +21,8 @@ VITE_OAUTH_REDIRECT_BASE=
# Self-serve signup safety fallback used by useAppConfig when GET /config/public
# is unreachable. Authoritative value comes from backend SELF_SERVE_ENABLED.
VITE_SELF_SERVE_ENABLED=false
# Calendly link surfaced on the /contact-sales confirmation screen. When unset,
# the "Want to skip ahead?" block is hidden. Vite bakes at build time, so prod
# requires ARG+ENV in frontend/Dockerfile.
VITE_CALENDLY_URL=

View File

@@ -22,6 +22,7 @@ ARG VITE_GOOGLE_CLIENT_ID
ARG VITE_MS_CLIENT_ID
ARG VITE_OAUTH_REDIRECT_BASE
ARG VITE_SELF_SERVE_ENABLED
ARG VITE_CALENDLY_URL
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY
@@ -31,6 +32,7 @@ ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
ENV VITE_MS_CLIENT_ID=$VITE_MS_CLIENT_ID
ENV VITE_OAUTH_REDIRECT_BASE=$VITE_OAUTH_REDIRECT_BASE
ENV VITE_SELF_SERVE_ENABLED=$VITE_SELF_SERVE_ENABLED
ENV VITE_CALENDLY_URL=$VITE_CALENDLY_URL
# Build the application
RUN npm run build

View File

@@ -12,6 +12,12 @@ 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 salesApi } from './sales'
export type {
SalesLeadCreatePayload,
SalesLeadCreateResponse,
SalesLeadSource,
} from './sales'
export { default as usageApi } from './usage'
export { default as adminApi } from './admin'
export { treeMarkdownApi } from './treeMarkdown'

32
frontend/src/api/sales.ts Normal file
View File

@@ -0,0 +1,32 @@
import apiClient from './client'
export type SalesLeadSource = 'pricing_page' | 'register_footer' | 'landing_page'
export interface SalesLeadCreatePayload {
email: string
name: string
company: string
team_size?: string
message?: string
source: SalesLeadSource
posthog_distinct_id?: string
}
export interface SalesLeadCreateResponse {
id: string
status: 'received'
}
export const salesApi = {
/**
* Public Talk-to-Sales submission. No auth required. Rate-limited per IP
* server-side (5/hour). Server emits PostHog `talk_to_sales_form_submitted`
* — frontend should NOT also fire this event.
*/
async createLead(payload: SalesLeadCreatePayload): Promise<SalesLeadCreateResponse> {
const response = await apiClient.post<SalesLeadCreateResponse>('/sales-leads', payload)
return response.data
},
}
export default salesApi

View File

@@ -0,0 +1,396 @@
import { useMemo, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { salesApi, type SalesLeadSource } from '@/api/sales'
import { PageMeta } from '@/components/common/PageMeta'
import { useAppConfig } from '@/hooks/useAppConfig'
import '@/styles/landing.css'
/* ---------------------------------------------------------------------------
* Source detection
*
* The backend `/sales-leads` endpoint requires a `source` enum. We classify
* by document.referrer: visitors landing on /contact-sales after viewing the
* pricing page get tagged `pricing_page`; everything else is `landing_page`.
* `register_footer` is reserved for the (future) sign-up footer CTA.
*
* Acceptable for v1 — server-side PostHog event uses this same `source` value.
* ------------------------------------------------------------------------- */
function detectSource(): SalesLeadSource {
if (typeof document === 'undefined') return 'landing_page'
const ref = document.referrer || ''
if (ref.includes('/pricing')) return 'pricing_page'
return 'landing_page'
}
const TEAM_SIZE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: '', label: 'Select team size' },
{ value: '1-2', label: '12' },
{ value: '3-5', label: '35' },
{ value: '6-10', label: '610' },
{ value: '11-25', label: '1125' },
{ value: '26+', label: 'More than 26' },
]
interface FormState {
name: string
email: string
company: string
team_size: string
message: string
}
const INITIAL: FormState = {
name: '',
email: '',
company: '',
team_size: '',
message: '',
}
function ContactSalesNotFound() {
return (
<div
data-testid="contact-sales-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>
)
}
export function ContactSalesPage() {
const appConfig = useAppConfig()
const [form, setForm] = useState<FormState>(INITIAL)
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [error, setError] = useState<string | null>(null)
const calendlyUrl = useMemo(() => {
const raw = import.meta.env.VITE_CALENDLY_URL
return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : ''
}, [])
// Self-serve disabled: 404. (Same pattern as PricingPage — done after hooks.)
if (!appConfig.isLoading && !appConfig.self_serve_enabled) {
return (
<>
<PageMeta title="Page not found" />
<ContactSalesNotFound />
</>
)
}
const handleChange =
(field: keyof FormState) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }))
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (submitting) return
const name = form.name.trim()
const email = form.email.trim()
const company = form.company.trim()
if (!name || !email || !company) {
setError('Please fill in name, work email, and company.')
return
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError('Enter a valid work email address.')
return
}
setSubmitting(true)
setError(null)
try {
await salesApi.createLead({
name,
email,
company,
team_size: form.team_size || undefined,
message: form.message.trim() || undefined,
source: detectSource(),
})
setSubmitted(true)
} catch {
// The backend may rate-limit (429) or reject for validation; surface a
// generic message and allow retry. Don't leak internal errors.
setError('Something went wrong. Please try again or email hello@resolutionflow.com.')
} finally {
setSubmitting(false)
}
}
return (
<div className="landing-page">
<PageMeta
title="Talk to Sales"
description="Get in touch with the ResolutionFlow team about Enterprise plans, custom seats, SSO, and onboarding for your MSP."
/>
<main
className="landing-main"
style={{ paddingTop: '4rem', paddingBottom: '4rem' }}
>
<section
style={{
maxWidth: '640px',
margin: '0 auto',
padding: '3rem 1.5rem 1.5rem',
textAlign: 'center',
}}
>
<h1
style={{
color: 'var(--lp-text-heading)',
fontSize: 'clamp(1.75rem, 3.5vw, 2.5rem)',
fontWeight: 700,
lineHeight: 1.15,
margin: '0 0 0.75rem',
}}
>
Talk to Sales
</h1>
<p
style={{
color: 'var(--lp-text-body)',
fontSize: '1.05rem',
margin: 0,
}}
>
Tell us about your MSP. We&rsquo;ll reach out within 1 business day.
</p>
</section>
<section
style={{
maxWidth: '560px',
margin: '0 auto',
padding: '1rem 1.5rem 3rem',
}}
>
{submitted ? (
<div
data-testid="contact-sales-confirmation"
style={{
background: 'var(--lp-card)',
border: '1px solid var(--lp-border)',
borderRadius: '12px',
padding: '2rem 1.75rem',
textAlign: 'center',
}}
>
<h2
style={{
color: 'var(--lp-text-heading)',
fontSize: '1.25rem',
fontWeight: 600,
margin: '0 0 0.75rem',
}}
>
Thanks &mdash; we&rsquo;ll reach out within 1 business day.
</h2>
{calendlyUrl && (
<div data-testid="calendly-block">
<p style={{ color: 'var(--lp-text-body)', margin: '0 0 1rem' }}>
Want to skip ahead?
</p>
<a
data-testid="calendly-link"
href={calendlyUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-block',
padding: '0.65rem 1.25rem',
background: 'var(--lp-accent)',
color: '#0d0f15',
borderRadius: '8px',
fontWeight: 600,
textDecoration: 'none',
}}
>
Book a time
</a>
</div>
)}
</div>
) : (
<form
data-testid="contact-sales-form"
onSubmit={handleSubmit}
noValidate
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
background: 'var(--lp-card)',
border: '1px solid var(--lp-border)',
borderRadius: '12px',
padding: '1.75rem',
}}
>
<Field label="Name" htmlFor="cs-name" required>
<input
id="cs-name"
data-testid="cs-name"
type="text"
required
value={form.name}
onChange={handleChange('name')}
autoComplete="name"
style={inputStyle}
/>
</Field>
<Field label="Work email" htmlFor="cs-email" required>
<input
id="cs-email"
data-testid="cs-email"
type="email"
required
value={form.email}
onChange={handleChange('email')}
autoComplete="email"
style={inputStyle}
/>
</Field>
<Field label="Company" htmlFor="cs-company" required>
<input
id="cs-company"
data-testid="cs-company"
type="text"
required
value={form.company}
onChange={handleChange('company')}
autoComplete="organization"
style={inputStyle}
/>
</Field>
<Field label="Team size" htmlFor="cs-team-size">
<select
id="cs-team-size"
data-testid="cs-team-size"
value={form.team_size}
onChange={handleChange('team_size')}
style={inputStyle}
>
{TEAM_SIZE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</Field>
<Field label="What brought you here?" htmlFor="cs-message">
<textarea
id="cs-message"
data-testid="cs-message"
rows={4}
value={form.message}
onChange={handleChange('message')}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</Field>
{error && (
<div
role="alert"
data-testid="cs-error"
style={{ color: '#f87171', fontSize: '0.9rem' }}
>
{error}
</div>
)}
<button
type="submit"
data-testid="cs-submit"
disabled={submitting}
style={{
padding: '0.75rem 1.25rem',
background: 'var(--lp-accent)',
color: '#0d0f15',
border: 'none',
borderRadius: '8px',
fontWeight: 600,
cursor: submitting ? 'not-allowed' : 'pointer',
opacity: submitting ? 0.7 : 1,
}}
>
{submitting ? 'Sending…' : 'Submit'}
</button>
</form>
)}
</section>
</main>
</div>
)
}
function Field({
label,
htmlFor,
required,
children,
}: {
label: string
htmlFor: string
required?: boolean
children: React.ReactNode
}) {
return (
<label
htmlFor={htmlFor}
style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}
>
<span
style={{
color: 'var(--lp-text-heading)',
fontSize: '0.9rem',
fontWeight: 500,
}}
>
{label}
{required && (
<span aria-hidden="true" style={{ color: 'var(--lp-accent)', marginLeft: '0.25rem' }}>
*
</span>
)}
</span>
{children}
</label>
)
}
const inputStyle: React.CSSProperties = {
padding: '0.6rem 0.75rem',
background: 'var(--lp-bg-alt, #1a1d26)',
border: '1px solid var(--lp-border)',
borderRadius: '8px',
color: 'var(--lp-text-heading)',
fontSize: '0.95rem',
fontFamily: 'inherit',
outline: 'none',
}
export default ContactSalesPage

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { Link } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
import { useAppConfig } from '@/hooks/useAppConfig'
import '@/styles/landing.css'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
@@ -29,6 +30,7 @@ const FAQ_ITEMS = [
]
export default function LandingPage() {
const appConfig = useAppConfig()
const [navScrolled, setNavScrolled] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [betaEmail, setBetaEmail] = useState('')
@@ -174,6 +176,15 @@ export default function LandingPage() {
</p>
<div className="landing-hero-actions">
<Link to="/register" className="landing-btn-hero-primary">Start Free</Link>
{appConfig.self_serve_enabled && (
<Link
to="/pricing"
className="landing-btn-hero-secondary"
data-testid="landing-see-pricing"
>
See pricing
</Link>
)}
<a href="#how-it-works" className="landing-btn-hero-secondary">See How It Works</a>
</div>
<p className="landing-hero-credibility">

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import { ContactSalesPage } from '../ContactSalesPage'
import { salesApi } from '@/api/sales'
import {
__resetAppConfigCache,
__setAppConfigCache,
} from '@/hooks/useAppConfig'
vi.mock('@/api/sales', () => ({
salesApi: {
createLead: vi.fn(),
},
}))
function renderPage() {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={['/contact-sales']}>
<ContactSalesPage />
</MemoryRouter>
</HelmetProvider>,
)
}
function fillRequiredFields() {
fireEvent.change(screen.getByTestId('cs-name'), { target: { value: 'Jane Doe' } })
fireEvent.change(screen.getByTestId('cs-email'), { target: { value: 'jane@acme.com' } })
fireEvent.change(screen.getByTestId('cs-company'), { target: { value: 'Acme MSP' } })
}
describe('ContactSalesPage', () => {
beforeEach(() => {
__resetAppConfigCache()
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
vi.clearAllMocks()
vi.unstubAllEnvs()
})
it('submits form and shows confirmation', async () => {
vi.stubEnv('VITE_CALENDLY_URL', 'https://calendly.com/resolutionflow/sales')
vi.mocked(salesApi.createLead).mockResolvedValue({
id: 'fake-uuid',
status: 'received',
})
renderPage()
fillRequiredFields()
fireEvent.change(screen.getByTestId('cs-team-size'), { target: { value: '11-25' } })
fireEvent.change(screen.getByTestId('cs-message'), {
target: { value: 'Looking at Enterprise pricing.' },
})
fireEvent.click(screen.getByTestId('cs-submit'))
await waitFor(() => {
expect(salesApi.createLead).toHaveBeenCalledTimes(1)
})
const payload = vi.mocked(salesApi.createLead).mock.calls[0][0]
expect(payload).toMatchObject({
name: 'Jane Doe',
email: 'jane@acme.com',
company: 'Acme MSP',
team_size: '11-25',
message: 'Looking at Enterprise pricing.',
})
// Default source is landing_page (no /pricing in referrer in jsdom).
expect(payload.source).toBe('landing_page')
// Confirmation surface replaces the form.
await waitFor(() => {
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
})
expect(screen.queryByTestId('contact-sales-form')).not.toBeInTheDocument()
expect(screen.getByText(/Thanks/i)).toBeInTheDocument()
})
it('hides Calendly section when VITE_CALENDLY_URL unset', async () => {
vi.stubEnv('VITE_CALENDLY_URL', '')
vi.mocked(salesApi.createLead).mockResolvedValue({
id: 'fake-uuid',
status: 'received',
})
renderPage()
fillRequiredFields()
fireEvent.click(screen.getByTestId('cs-submit'))
await waitFor(() => {
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
})
expect(screen.queryByTestId('calendly-block')).not.toBeInTheDocument()
expect(screen.queryByTestId('calendly-link')).not.toBeInTheDocument()
})
it('disables submit button while in flight to prevent duplicate submissions', async () => {
let resolveSubmit: (() => void) | null = null
vi.mocked(salesApi.createLead).mockImplementation(
() =>
new Promise((resolve) => {
resolveSubmit = () => resolve({ id: 'fake-uuid', status: 'received' })
}),
)
renderPage()
fillRequiredFields()
const submit = screen.getByTestId('cs-submit') as HTMLButtonElement
fireEvent.click(submit)
await waitFor(() => {
expect(submit.disabled).toBe(true)
})
// A second click while in flight should be a no-op.
fireEvent.click(submit)
expect(salesApi.createLead).toHaveBeenCalledTimes(1)
resolveSubmit?.()
await waitFor(() => {
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
})
})
it('returns 404 when self_serve_enabled is false', () => {
__resetAppConfigCache()
__setAppConfigCache({
self_serve_enabled: false,
oauth_providers: [],
})
renderPage()
expect(screen.getByTestId('contact-sales-not-found')).toBeInTheDocument()
expect(screen.queryByTestId('contact-sales-form')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import LandingPage from '../LandingPage'
import {
__resetAppConfigCache,
__setAppConfigCache,
} from '@/hooks/useAppConfig'
// jsdom does not provide IntersectionObserver. LandingPage uses it for
// scroll-reveal animations; stub a no-op so the page can mount.
beforeAll(() => {
// @ts-expect-error — test-only stub
globalThis.IntersectionObserver = class {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return []
}
}
})
function renderPage() {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={['/']}>
<LandingPage />
</MemoryRouter>
</HelmetProvider>,
)
}
describe('LandingPage', () => {
beforeEach(() => {
__resetAppConfigCache()
vi.clearAllMocks()
})
it('shows See pricing CTA when self_serve_enabled is true', async () => {
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
renderPage()
await waitFor(() => {
expect(screen.getByTestId('landing-see-pricing')).toBeInTheDocument()
})
const cta = screen.getByTestId('landing-see-pricing')
expect(cta).toHaveAttribute('href', '/pricing')
expect(cta).toHaveTextContent(/See pricing/i)
})
it('hides See pricing CTA when self_serve_enabled is false', async () => {
__setAppConfigCache({
self_serve_enabled: false,
oauth_providers: [],
})
renderPage()
// Hero "Start Free" still renders, but the gated /pricing CTA does not.
expect(screen.queryByTestId('landing-see-pricing')).not.toBeInTheDocument()
})
})

View File

@@ -23,6 +23,7 @@ const SurveyThankYouPage = lazyWithRetry(() => import('@/pages/SurveyThankYouPag
const PrivacyPage = lazyWithRetry(() => import('@/pages/PrivacyPage'))
const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage'))
const PricingPage = lazyWithRetry(() => import('@/pages/PricingPage'))
const ContactSalesPage = lazyWithRetry(() => import('@/pages/ContactSalesPage'))
// Standalone auth pages
const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage'))
@@ -137,6 +138,11 @@ export const router = sentryCreateBrowserRouter([
element: page(PricingPage),
errorElement: <RouteError />,
},
{
path: '/contact-sales',
element: page(ContactSalesPage),
errorElement: <RouteError />,
},
{
path: '/login',
element: <LoginPage />,