From db2478dd892522c8114d598448c6b0aac8fb83d3 Mon Sep 17 00:00:00 2001
From: Michael Chihlas
Date: Wed, 6 May 2026 23:31:56 -0400
Subject: [PATCH] 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)
---
frontend/.env.example | 5 +
frontend/Dockerfile | 2 +
frontend/src/api/index.ts | 6 +
frontend/src/api/sales.ts | 32 ++
frontend/src/pages/ContactSalesPage.tsx | 396 ++++++++++++++++++
frontend/src/pages/LandingPage.tsx | 11 +
.../pages/__tests__/ContactSalesPage.test.tsx | 146 +++++++
.../src/pages/__tests__/LandingPage.test.tsx | 69 +++
frontend/src/router.tsx | 6 +
9 files changed, 673 insertions(+)
create mode 100644 frontend/src/api/sales.ts
create mode 100644 frontend/src/pages/ContactSalesPage.tsx
create mode 100644 frontend/src/pages/__tests__/ContactSalesPage.test.tsx
create mode 100644 frontend/src/pages/__tests__/LandingPage.test.tsx
diff --git a/frontend/.env.example b/frontend/.env.example
index 574a1872..9c2f59cb 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -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=
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 6bcdc0c8..66b67c4a 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -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
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index 64ff005d..3c079da1 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -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'
diff --git a/frontend/src/api/sales.ts b/frontend/src/api/sales.ts
new file mode 100644
index 00000000..5501d3bc
--- /dev/null
+++ b/frontend/src/api/sales.ts
@@ -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 {
+ const response = await apiClient.post('/sales-leads', payload)
+ return response.data
+ },
+}
+
+export default salesApi
diff --git a/frontend/src/pages/ContactSalesPage.tsx b/frontend/src/pages/ContactSalesPage.tsx
new file mode 100644
index 00000000..dd083ba6
--- /dev/null
+++ b/frontend/src/pages/ContactSalesPage.tsx
@@ -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: '1–2' },
+ { value: '3-5', label: '3–5' },
+ { value: '6-10', label: '6–10' },
+ { value: '11-25', label: '11–25' },
+ { 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 (
+
+
Page not found
+
This page is not available.
+
+ Go to login
+
+
+ )
+}
+
+export function ContactSalesPage() {
+ const appConfig = useAppConfig()
+ const [form, setForm] = useState(INITIAL)
+ const [submitting, setSubmitting] = useState(false)
+ const [submitted, setSubmitted] = useState(false)
+ const [error, setError] = useState(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 (
+ <>
+
+
+ >
+ )
+ }
+
+ const handleChange =
+ (field: keyof FormState) =>
+ (e: React.ChangeEvent) => {
+ 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 (
+
+
+
+
+
+
+ Talk to Sales
+
+
+ Tell us about your MSP. We’ll reach out within 1 business day.
+
+
+
+
+ {submitted ? (
+
+
+ Thanks — we’ll reach out within 1 business day.
+
+ {calendlyUrl && (
+
+ )}
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
+
+function Field({
+ label,
+ htmlFor,
+ required,
+ children,
+}: {
+ label: string
+ htmlFor: string
+ required?: boolean
+ children: React.ReactNode
+}) {
+ return (
+
+ )
+}
+
+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
diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx
index aaff9d10..0989f1d8 100644
--- a/frontend/src/pages/LandingPage.tsx
+++ b/frontend/src/pages/LandingPage.tsx
@@ -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() {
Start Free
+ {appConfig.self_serve_enabled && (
+
+ See pricing
+
+ )}
See How It Works
diff --git a/frontend/src/pages/__tests__/ContactSalesPage.test.tsx b/frontend/src/pages/__tests__/ContactSalesPage.test.tsx
new file mode 100644
index 00000000..334ea95f
--- /dev/null
+++ b/frontend/src/pages/__tests__/ContactSalesPage.test.tsx
@@ -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(
+
+
+
+
+ ,
+ )
+}
+
+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()
+ })
+})
diff --git a/frontend/src/pages/__tests__/LandingPage.test.tsx b/frontend/src/pages/__tests__/LandingPage.test.tsx
new file mode 100644
index 00000000..795c6d37
--- /dev/null
+++ b/frontend/src/pages/__tests__/LandingPage.test.tsx
@@ -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(
+
+
+
+
+ ,
+ )
+}
+
+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()
+ })
+})
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index 2a7db0ca..df12a5ff 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -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: ,
},
+ {
+ path: '/contact-sales',
+ element: page(ContactSalesPage),
+ errorElement: ,
+ },
{
path: '/login',
element: ,